The whole strategy
In short: The game ends with the first of five checks that says “yes”: conquest, defeat, science, economy or the deadline (points). A save is “key=value” text with a version and a checksum, loading checks everything before it touches the game, and balance is measured with hundreds of games without a screen.
Problem
When does the game end?
For five lessons we built parts: a galaxy from a seed, a technology tree, fleets and battles, rivals with a character, and a turn with phases and events. But the game still has no end. If nobody ever wins, the game never finishes, and if victory is badly designed, the player plays a hundred and fifty turns and finds out that everything was decided in the first twenty.
This lesson closes the module with the three things every complete game stands on: victory (when and how a game ends), saving (how you continue tomorrow) and balance (how you know the game is fair without playing it a thousand times by hand). Then the game is here, whole.
Here is how the playthrough that the site uses to check the game ends, on all four languages. The Terran Empire researches the Stargate on turn 59, and the end screen shows the standings:
GAME OVER
Terran Empire researched the Stargate on turn 59.
Standings (planets, technologies, score):A Terran Empire 7 13 376C Lyra Union 0 3 38 destroyedD Vega Academy 7 12 319B Orion Hegemony 2 8 154
Battles 27, units lost 159, colonies 12, technologies 36,events 27.
(Enter: exit)Three things show up on this screen: the kind of victory (science), the standings (planets, technologies and points of every empire; Lyra Union is destroyed) and the statistics of the game. They come from three small functions that you will see here.
The idea
Four victories, one chain of checks
The game has four ways to end, and one more so that it never hangs forever:
- Conquest: only one empire is left alive.
- Defeat: the player is destroyed (the game ends, and the strongest of the rest is recorded as the winner).
- Science: some living empire has researched the Stargate, the technology at the end of the tree.
- Economy: some living empire has produced 9800 in total.
- Deadline: turn 150 has come and whoever has the most points wins.
An empire’s points are 10 per planet, 6 per technology, the fleet’s power divided by 10, and the production divided by 40. Here is the real code from the game:
// Three victories: conquest (only one empire is left), science (the Stargate is researched) and// economy (production ECON_GOAL accumulated). If nobody wins by MAX_TURNS, the one// with the most points wins. If the player is destroyed, the game ends in defeat.function empireScore(world, empire) { return ownedPlanets(world, empire.id).length * 10 + empire.techs.length * 6 + Math.floor(empirePower(world, empire.id) / 10) + Math.floor(empire.produced / 40);}
function bestScoring(world) { let best = null; let bestScore = -1; for (const e of world.empires) { if (!e.alive) continue; const score = empireScore(world, e); if (score > bestScore) { bestScore = score; best = e; } } return best;}
function checkVictory(world) { if (world.victory !== null) return; if (world.livingCount() === 1) { for (const e of world.empires) { if (e.alive) world.victory = { empire: e.id, kind: 'conquest', turn: world.turn }; } return; } for (const e of world.empires) { if (e.human && !e.alive) { world.victory = { empire: bestScoring(world).id, kind: 'lost', turn: world.turn }; return; } } for (const e of world.empires) { if (e.alive && knows(e, 'stargate')) { world.victory = { empire: e.id, kind: 'science', turn: world.turn }; return; } } for (const e of world.empires) { if (e.alive && e.produced >= ECON_GOAL) { world.victory = { empire: e.id, kind: 'economy', turn: world.turn }; return; } } if (world.turn >= MAX_TURNS) world.victory = { empire: bestScoring(world).id, kind: 'points', turn: world.turn };}checkVictory is not one check but five in an exact order, and the first one that says “yes” decides. That is an early exit: as soon as you know the answer, you return. The order is not random. If only one empire is alive, there is no point asking about the gate. If the player is destroyed, the game is lost, however much someone has produced. And the deadline is last because it is the weakest victory.
The small program does the same with the same rules, but with three empires and no galaxy. An empire knows how many points it has:
class Empire { constructor(name, human, planets, techs, power, produced) { this.name = name; this.human = human; this.alive = true; this.stargate = false; this.planets = planets; this.techs = techs; this.power = power; this.produced = produced; }
score() { return this.planets * 10 + this.techs * 6 + Math.floor(this.power / 10) + Math.floor(this.produced / 40); }}class Victory { constructor(kind, winner) { this.kind = kind; this.winner = winner; }}The checks are the same five, in the same order (ECON_GOAL is 9800, MAX_TURNS is 150):
function checkVictory(empires, turn) { const living = empires.filter((e) => e.alive); if (living.length === 1) return new Victory("conquest", living[0].name); for (const e of empires) { if (e.human && !e.alive) return new Victory("defeat", bestScoring(empires).name); } for (const e of empires) { if (e.alive && e.stargate) return new Victory("science", e.name); } for (const e of empires) { if (e.alive && e.produced >= ECON_GOAL) return new Victory("economy", e.name); } if (turn >= MAX_TURNS) return new Victory("points", bestScoring(empires).name); return null;}Seven worlds, seven answers. The last two show the most important thing: a destroyed player gives defeat, and turn 150 with no other victory gives points.
let game = fresh();show("turn 40, nothing has ended", game, 40);
game = fresh();game[2].stargate = true;show("Stargate researched", game, 40);
game = fresh();game[1].produced = ECON_GOAL;show("9800 produced", game, 40);
game = fresh();game[2].stargate = true;game[2].produced = ECON_GOAL;show("both the gate and 9800 in one turn", game, 40);
game = fresh();game[1].alive = false;game[2].alive = false;show("one empire is left", game, 40);
game = fresh();game[0].alive = false;show("the player is destroyed", game, 40);
show("turn 150, no other victory", fresh(), 150);turn 40, nothing has ended: -Stargate researched: science (Vega Academy)9800 produced: economy (Orion Hegemony)both the gate and 9800 in one turn: science (Vega Academy)one empire is left: conquest (Terran Empire)the player is destroyed: defeat (Orion Hegemony)turn 150, no other victory: points (Terran Empire)Every case starts from the same fresh game and changes one or two things. The fourth is the tricky one: Vega Academy has researched the Stargate and produced 9800 in the same turn. Science wins, because it comes earlier in the order.
See it
The first “yes” decides
Four of these cases in motion. Watch which checks run and which go dark because they are never reached:
function checkVictory(empires, turn) { const living = empires.filter((e) => e.alive); if (living.length === 1) return new Victory("conquest", living[0].name); for (const e of empires) { if (e.human && !e.alive) return new Victory("defeat", bestScoring(empires).name); } for (const e of empires) { if (e.alive && e.stargate) return new Victory("science", e.name); } for (const e of empires) { if (e.alive && e.produced >= ECON_GOAL) return new Victory("economy", e.name); } if (turn >= MAX_TURNS) return new Victory("points", bestScoring(empires).name); return null;}Step by step, in words
- After every turn the world asks five questions in order: is one empire left, is the player destroyed, is there a Stargate, has anyone produced 9800, is it turn 150. The first “yes” decides.
- Turn 40. All three empires are alive, nobody has the Stargate, nobody has reached 9800.
- Five times “no”. checkVictory returns null and the game moves on to the next turn.
- Vega Academy: the Stargate is researched and 9800 is produced, both in the same turn. Two victories at once.
- The third check (science) says “yes” and returns at once. The fourth, the economy, is never reached: the order of the checks decides who wins.
- Orion Hegemony and Vega Academy are destroyed. One empire is left: Terran Empire.
- The first check says “yes”: conquest. The four after it never run.
- Turn 150: the deadline. Nobody has won any other way.
- Four “no”s and then the deadline: whoever has the most points wins. Terran Empire and Orion Hegemony are tied, and the earlier one takes a tie.
Why does the first “yes” return at once? Because there is nothing left to decide: a game has one victory. But there is a practical reason too. If the fourth check ran after the third, the function would have to choose between two victories, and that is a new rule. With an early exit the rule is the order, and you can read it straight from the code: swap two lines and you have changed who wins when conditions overlap.
More precisely, if you are curious
Here victory is checked once per turn, after everything else in the turn is done. That way a player who reached 9800 does not win in the middle of the turn, before the rivals have played theirs. In the turn region of the canon checkVictory(world) is the last phase of the turn, after production, research and the event.
The game keeps the victory in the world (world.victory) instead of returning it: the end screen, the save and the simulation all read the same place. That way the same check works for a player and for a simulation without a screen.
The idea
Points: ties and the destroyed
The deadline is the simplest of the four, but it has two hidden rules. First, destroyed empires do not count: an empire with many points that has already vanished must not win. Second, ties: two empires with equal points need a predictable winner, or the simulation is not deterministic.
function bestScoring(empires) { let best = null; let bestScore = -1; for (const e of empires) { if (!e.alive) continue; const score = e.score(); if (score > bestScore) { bestScore = score; best = e; } } return best;}bestScore starts at −1 so that even an empire with zero points can become the leader. The comparison is strict (>), so on a tie the earlier one stays. The program compares four empires, one destroyed and two with equal points:
const rivals = [ new Empire("Terran Empire", false, 5, 9, 400, 3000), new Empire("Orion Hegemony", false, 3, 11, 450, 3120), new Empire("Vega Academy", false, 4, 8, 300, 2000),];const lyra = new Empire("Lyra Union", false, 8, 14, 900, 6000);lyra.alive = false;rivals.push(lyra);
for (const e of rivals) { const note = e.alive ? "" : " (destroyed)"; console.log(e.name + ": " + e.score() + note);}console.log("Most points: " + bestScoring(rivals).name);Terran Empire: 219Orion Hegemony: 219Vega Academy: 168Lyra Union: 404 (destroyed)Most points: Terran EmpireLyra Union has the most points (404), but it is destroyed and drops out. Terran Empire and Orion Hegemony are both at 219, and the winner is the first. The divisions are integer divisions, as everywhere in the game (Math.floor in JavaScript), and that is why the points are the same on all four languages.
The idea
Saving: “key=value” text
A player who closes the game on turn 80 wants to continue from turn 80 tomorrow. Saving is reading the world back, and it has three questions: what is saved, in what form, and how you know the file is whole.
What. Not everything. The galaxy (systems, lanes, kinds of planets) is made from the seed: the same seed, the same galaxy (lesson 1 of the module). So a save keeps only the things that change: whose every planet is, what is built, the resources, technologies, fleets, the build queue, peace and war, and the state of the generator so that the dice continue from the same place.
In what form. Text, line by line, key=value. You can read it at a glance, compare it with another save, fix it by hand in an emergency, and the format is not tied to the language of the game: the same text can be written and read by a version in any language. The first line is a header (format=COSMOS), the second is the version of the format, and the last is a checksum of everything above it.
function saveWorld(world) { const lines = []; const put = (key, value) => lines.push(key + '=' + value); put('format', SAVE_FORMAT); put('version', SAVE_VERSION); put('summary', saveSummary(world)); put('seed', world.seed); put('rng', world.rng.state); put('turn', world.turn); put('systems', world.systems.length); put('planets', world.planets.length); put('empires', world.empires.length); for (const planet of world.planets) { put('planet.' + planet.id, [planet.owner, planet.mines, planet.lab, planet.yard, planet.fort, planet.siege].concat(planet.rich).join(',')); } for (const empire of world.empires) { const prefix = 'empire.' + empire.id; put(prefix, [empire.def.id, empire.personality.id, empire.alive ? 1 : 0, empire.progress, empire.produced, empire.flare].concat(empire.res).join(',')); put(prefix + '.researching', empire.researching === '' ? '-' : empire.researching); put(prefix + '.techs', joinOrDash(empire.techs)); put(prefix + '.war', empire.war.join(',')); put(prefix + '.since', empire.since.join(',')); put(prefix + '.grudge', empire.grudge.join(',')); const orders = []; for (const order of empire.orders) orders.push([order.from, order.to, order.dest].concat(order.ships).join(':')); put(prefix + '.orders', joinOrDash(orders)); } put('fleets', world.fleets.length); for (let i = 0; i < world.fleets.length; i++) { const fleet = world.fleets[i]; put('fleet.' + i, [fleet.owner, fleet.systemId].concat(fleet.ships).join(',')); } put('queue', world.queue.length); for (let i = 0; i < world.queue.length; i++) { const item = world.queue[i]; put('queue.' + i, [item.owner, item.planetId, item.kind, item.count, item.left].join(',')); } const offers = []; for (const offer of world.offers) offers.push(offer.from + ':' + offer.to + ':' + offer.turn); put('offers', joinOrDash(offers)); const s = world.stats; put('stats', [s.battles, s.lost, s.colonies, s.techs, s.events].join(',')); const body = lines.join('\n') + '\n'; return body + 'checksum=' + checksum(body) + '\n';}The small program saves the same in miniature: a seed, a turn and three empires (planets, produced, technologies), with a version and a checksum at the end:
class Game { constructor(seed, turn) { this.seed = seed; this.turn = turn; this.empires = []; }}function save(game) { const lines = ["format=COSMOS", "version=" + VERSION]; lines.push("seed=" + game.seed); lines.push("turn=" + game.turn); lines.push("empires=" + game.empires.length); game.empires.forEach((e, i) => lines.push("empire." + i + "=" + e.join(","))); const body = lines.join("\n") + "\n"; return body + "checksum=" + checksum(body) + "\n";}process.stdout.write(save(sample()));format=COSMOSversion=1seed=2026turn=12empires=3empire.0=5,3120,9empire.1=4,2750,7empire.2=3,4180,10checksum=987468948That is the save, line by line. The version is there not for today but for tomorrow: when the format changes (a new key, another order), old saves must be recognised and either rejected or converted. Without a version, a save from an old version would be read wrongly and silently.
The idea
Checksum: one change and the sum differs
The last line of a save is the number you get from everything above it. When loading, the game computes it again and compares it with the stored one: if the file is cut off, broken or touched by hand, the numbers differ. The algorithm is small:
function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; return sum;}For every character the sum is multiplied by 31, the character’s code is added, and the remainder modulo 1000000007 is taken. The multiplication by 31 is the important part: it makes order matter. A plain sum of codes would give the same number for “ab” and “ba”, and two swapped digits in a save are exactly the mistake we want to catch.
const texts = ["ab", "ba", "turn=12", "turn=21"];for (const word of texts) { console.log(word + " -> " + checksum(word));}ab -> 3105ba -> 3135turn=12 -> 408655515turn=21 -> 408655545“ab” is 97 × 31 + 98 = 3105, and “ba” is 98 × 31 + 97 = 3135. The remainder keeps the number below a billion, so that it fits in 32 bits, and JavaScript computes exactly up to 253, and the intermediate result here is far smaller.
function checksum(text) {
let sum = 0;
for (let i = 0; i < text.length; i++) {
sum = (sum * 31 + text.charCodeAt(i)) 1000000007;
}
return sum;
}The remainder (%) brings the sum back below 1000000007 after every character. Without it the number would grow as long as the save is long.
More precisely, if you are curious
A checksum catches mistakes, not tampering. Whoever knows the formula changes a value and recomputes the sum (the program below does exactly that with reseal, to test its other defences). For a game kept on your own disk that is enough: the goal is not to load half a save. For a competitive leaderboard on a server you need something else: a signature with a secret key (HMAC) that the player cannot compute.
The algorithm belongs to the family of “polynomial sums”, the same as hashCode of a string in Java. For real hashes (SHA-256) there are ready libraries, but the game keeps a 32-bit check with nothing from outside: you can read it at a glance, and it gives the same numbers on all four languages.
The idea
A loader that trusts nobody
A save comes from the disk, and the disk can return anything: a truncated file, a save from another version of the game, a file edited in a text editor. If the game reads it on trust, one bad save crashes it or (worse) leaves it in a half state that shows up only on turn 90. So loading checks everything before it touches the game, and says exactly what is wrong.
class SaveReader { constructor(text) { this.values = new Map(); const lines = text.split('\n'); while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop(); if (lines.length === 0 || lines[0] !== 'format=' + SAVE_FORMAT) throw new SaveError(t('load.badHeader')); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf('='); if (eq <= 0) throw new SaveError(t('load.badLine', { line: i + 1 })); this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = this.text('version'); if (version !== String(SAVE_VERSION)) throw new SaveError(t('load.badVersion', { found: version, expected: SAVE_VERSION })); const last = lines[lines.length - 1]; if (!last.startsWith('checksum=')) throw new SaveError(t('load.missing', { key: 'checksum' })); const body = lines.slice(0, lines.length - 1).join('\n') + '\n'; const actual = checksum(body); if (parseIntStrict(last.substring(9)) !== actual) { throw new SaveError(t('load.badChecksum', { expected: last.substring(9), actual: actual })); } }
text(key) { if (!this.values.has(key)) throw new SaveError(t('load.missing', { key: key })); return this.values.get(key); }
// A number within the bounds [low, high]. int(key, low, high) { const value = parseIntStrict(this.text(key)); if (value === null || value < low || value > high) throw new SaveError(t('load.badValue', { key: key })); return value; }
// A list of exactly count numbers, each within its bounds. ints(key, count, low, high) { const parts = this.text(key).split(','); if (parts.length !== count) throw new SaveError(t('load.badValue', { key: key })); const result = []; for (const part of parts) { const value = parseIntStrict(part); if (value === null || value < low || value > high) throw new SaveError(t('load.badValue', { key: key })); result.push(value); } return result; }
list(key) { return splitList(this.text(key)); }}The order of the checks matters: the header, every line being key=value, the version, the checksum, and only then the values. Every value is read through int or ints, which check the ranges (a turn from 1 to 150, a number of empires from 1 to 9). The small version is the same in miniature:
class SaveReader { constructor(text) { this.values = new Map(); const lines = text.split("\n"); while (lines.length > 0 && lines[lines.length - 1].length === 0) lines.pop(); if (lines.length === 0 || lines[0] !== "format=COSMOS") throw new SaveError("this is not an Epic Cosmos save"); for (let i = 0; i < lines.length; i++) { const eq = lines[i].indexOf("="); if (eq <= 0) throw new SaveError("line " + (i + 1) + " is not \"key=value\""); this.values.set(lines[i].substring(0, eq), lines[i].substring(eq + 1)); } const version = this.text("version"); if (version !== String(VERSION)) throw new SaveError("version " + version + ", but the game reads version " + VERSION); const last = lines[lines.length - 1]; if (!last.startsWith("checksum=")) throw new SaveError("\"" + "checksum" + "\" is missing"); const body = lines.slice(0, lines.length - 1).join("\n") + "\n"; const actual = checksum(body); if (digits(last.substring(9)) !== actual) { throw new SaveError("checksum mismatch (stored " + last.substring(9) + ", computed " + actual + "), the file was edited"); } }
text(key) { if (!this.values.has(key)) throw new SaveError("\"" + key + "\" is missing"); return this.values.get(key); }
integer(key, low, high) { const value = digits(this.text(key)); if (value < low || value > high) throw new SaveError("wrong value of \"" + key + "\""); return value; }
numbers(key, count, low, high) { const parts = this.text(key).split(","); if (parts.length !== count) throw new SaveError("wrong value of \"" + key + "\""); const result = []; for (const part of parts) { const value = digits(part); if (value < low || value > high) throw new SaveError("wrong value of \"" + key + "\""); result.push(value); } return result; }}function load(text) { const reader = new SaveReader(text); const game = new Game(reader.integer("seed", 0, 99999), reader.integer("turn", 1, MAX_TURNS)); const count = reader.integer("empires", 1, 9); for (let i = 0; i < count; i++) { game.empires.push(reader.numbers("empire." + i, 3, 0, 99999)); } return game;}load creates a new game from what it read and returns it, without touching the current one. The error is a SaveError exception that carries the words for the player. The caller catches it and shows it, and the game goes on with the old world:
function showLoad(label, text) { try { const game = load(text); let produced = 0; for (const e of game.empires) produced += e[1]; console.log(label + ": " + "turn " + game.turn + ", empires " + game.empires.length + ", produced " + produced); } catch (error) { if (!(error instanceof SaveError)) throw error; console.log(label + ": " + error.message); }}Six saves, six answers. Two of them are altered so that the sum passes (which is why the range and key checks are needed), and one is touched by hand and the sum catches it:
const good = save(sample());const body = good.substring(0, good.indexOf("checksum="));showLoad("intact save", good);showLoad("edited by hand", good.replace("3120", "99999"));showLoad("other version", good.replace("version=1", "version=2"));showLoad("number out of range", reseal(body.replace("turn=12", "turn=999")));showLoad("missing line", reseal(body.replace("seed=2026\n", "")));showLoad("not a save", "hello");intact save: turn 12, empires 3, produced 10050edited by hand: checksum mismatch (stored 987468948, computed 410613307), the file was editedother version: version 2, but the game reads version 1number out of range: wrong value of "turn"missing line: "seed" is missingnot a save: this is not an Epic Cosmos saveThe real game does the same. Slot 3 of the playthrough holds a deliberately broken save, like hand-raised resources, and the game rejects it without crashing and without touching the current game:
> slotsSaves (save N, load N)Slot 1: Turn 5, Terran Empire, 2 planetsSlot 2: emptySlot 3: Turn 99, no guard> load 3The save is corrupted: checksum mismatch (stored 123456,computed 37865202), the file was edited.Finally the canon ties the two together: loadWorld catches SaveError and returns either a new world or a message. That way the screen never sees a broken game.
// Returns { world, error }: either the new world, or a message about what is wrong.function loadWorld(text) { try { return { world: readWorld(text), error: null }; } catch (error) { if (error instanceof SaveError) return { world: null, error: error.message }; throw error; }}The idea
Balance: how you know the game is fair
The game has three AI characters: warlord (values attack), builder (expansion and building) and scholar (science and the Stargate): a character is a set of weights for the empire’s decisions. Are they fair? If one wins 80% of the games, the others are decoration. You cannot play a thousand games by hand, but a computer does it in seconds: it plays against itself.
--simulate=200 runs two hundred games without a screen, AI against AI. Everything that helps: the game does not print by itself (the screen is separate), randomness has a seed (the result is repeatable) and the characters rotate through the seats:
// The balance: hundreds of games without a screen, artificial intelligence against artificial intelligence. The characters// rotate through the seats, so everyone plays equally often in every position; the table says how many// games each character wins and how the games end.const PERSONALITY_IDS = ['warlord', 'builder', 'scholar'];const VICTORY_KINDS = ['conquest', 'science', 'economy', 'points'];
function simulate(io, games, seed) { io.write(t('sim.title', { games: games, seed: seed }) + '\n'); io.write(t('sim.note1', { turns: MAX_TURNS, goal: ECON_GOAL }) + '\n\n'); const played = [0, 0, 0]; const wins = [0, 0, 0]; const winTurns = [0, 0, 0]; const kinds = [0, 0, 0, 0]; let totalTurns = 0; let totalSystems = 0; for (let i = 0; i < games; i++) { const order = []; for (let k = 0; k < 4; k++) order.push(PERSONALITY_IDS[(i + k) % 3]); const world = newWorld(seed + i, false, order, true); totalSystems += world.systems.length; while (world.victory === null) runTurn(world); for (const empire of world.empires) played[empire.personality.index]++; const winner = world.empires[world.victory.empire]; wins[winner.personality.index]++; winTurns[winner.personality.index] += world.victory.turn; kinds[VICTORY_KINDS.indexOf(world.victory.kind)]++; totalTurns += world.victory.turn; } io.write(pad(t('sim.personality'), 20) + padLeft(t('sim.played'), 10) + padLeft(t('sim.wins'), 10) + padLeft(t('sim.share'), 8) + padLeft(t('sim.avgTurn'), 12) + '\n'); for (const personality of PERSONALITIES.values()) { const i = personality.index; const avg = wins[i] === 0 ? 0 : Math.floor(winTurns[i] / wins[i]); io.write(pad(cap(nm(personality)), 20) + padLeft(String(played[i]), 10) + padLeft(String(wins[i]), 10) + padLeft(percent(wins[i], played[i]) + '%', 8) + padLeft(String(avg), 12) + '\n'); } const parts = []; for (let k = 0; k < VICTORY_KINDS.length; k++) parts.push(t('victory.' + VICTORY_KINDS[k]) + ' ' + kinds[k]); io.write('\n' + t('sim.kinds', { list: parts.join(', ') }) + '\n'); io.write(t('sim.averages', { turns: Math.floor(totalTurns / games), systems: Math.floor(totalSystems / games) }) + '\n');}The rotation (i + k) % 3 gives every character every seat equally often. Otherwise the first seat (on a tie the earlier one wins) would raise the percentage of whoever is always first.
A small version of the same idea. Three characters with the same average income, 30 per turn, but different spread: the warlord has luck and misses, the builder is steady. The first to 600 wins:
function playGame(rng, seats) { const produced = seats.map(() => 0); let turn = 0; while (true) { turn++; for (let k = 0; k < seats.length; k++) { const p = seats[k]; produced[k] += BASE[p] + rng.nextInt(SPREAD[p]); } for (let k = 0; k < seats.length; k++) { if (produced[k] >= GOAL) return [seats[k], turn]; } }}const games = 200;const rng = new Random(7);const played = [0, 0, 0];const wins = [0, 0, 0];const winTurns = [0, 0, 0];let totalTurns = 0;for (let i = 0; i < games; i++) { const seats = [0, 1, 2, 3].map((k) => (i + k) % 3); for (const p of seats) played[p]++; const [winner, turn] = playGame(rng, seats); wins[winner]++; winTurns[winner] += turn; totalTurns += turn;}
const names = ["Warlord", "Builder", "Scholar"];console.log("Balance simulation: " + games + " games, seed 7, goal " + GOAL + ".");console.log();console.log(pad("Character", 20) + padLeft("Games", 10) + padLeft("Wins", 10) + padLeft("Share", 8) + padLeft("Avg turn", 12));for (let i = 0; i < 3; i++) { const share = Math.floor((wins[i] * 100) / played[i]); const avg = wins[i] === 0 ? 0 : Math.floor(winTurns[i] / wins[i]); console.log(pad(names[i], 20) + padLeft(String(played[i]), 10) + padLeft(String(wins[i]), 10) + padLeft(share + "%", 8) + padLeft(String(avg), 12));}console.log();console.log("Average winning turn: " + Math.floor(totalTurns / games) + ".");Balance simulation: 200 games, seed 7, goal 600.
Character Games Wins Share Avg turnWarlord 267 79 29% 19Builder 267 46 17% 20Scholar 266 75 28% 20
Average winning turn: 19.The average income is the same, yet the builder wins only 17% against 29% for the warlord and 28% for the scholar. Why? In a race to a goal, whoever is ahead at some moment wins, not whoever has the best average. A steady player is never far from the middle, but rarely first; a spread-out one sometimes breaks away. So the average is not enough, and that is why you simulate: the number comes out of the game itself.
And here is the real simulation of Epic Cosmos: two hundred games, seed 7, from simulate.bg.out. The file is part of the check, like the playthrough:
| Character | Games | Wins | Share | Avg turn |
|---|---|---|---|---|
| Warlord | 223 | 61 | 27% | 47 |
| Builder | 220 | 59 | 26% | 57 |
| Scholar | 213 | 80 | 37% | 56 |
Wins by kind: conquest 50, science 90, economy 60, points 0. The average end of a game is on turn 53, and a galaxy has 15 systems on average.
What does the table say? The scholar wins most often (37%), the warlord and the builder are close. Points: 0 out of 200. Not a single game reaches turn 150, because someone conquers, researches the gate or produces 9800 much earlier. The deadline is a safety net, not a victory the AI chooses. That is a good sign: points are for a game in which nobody does anything.
Balance is tuned with the numbers in the data (the price of a technology, the income of a mine, the strength of a ship), and after every change you run the simulation again. If the scholar is at 37%, you try a more expensive Stargate and watch how the table moves.
More precisely, if you are curious
The simulation is also a test of the game. Two runs of two hundred games with the same seed must give the same file, and that is the check: simulate.bg.out is compared byte by byte on all four languages. If a port has a difference in one die roll, the game takes another path and the table differs. So one table of hundreds of games catches mistakes that the playthrough of 192 commands does not reach.
Try it
Play
This is the whole game, the JavaScript version of the canon, running on the page. You are the Terran Empire; the map shows the systems and the lanes between them. There are three ways to win: research the Stargate, produce 9800, or let your rivals destroy each other. The earlier the better, but the deadline is turn 150.
Epic Cosmos: the empire
Four empires, one galaxy and a hundred and fifty turns. Victory is science, economy or conquest.
Type the commands on the line below and press Enter (help lists them all; an empty line is map). Saves stay in this browser.
Commands (type them on the line under the screen): help lists them all, map is the map, sys 3 is system 3, tech is the technology tree, r stargate researches the Stargate, b mine 2 builds a mine on planet 2, ship i 2 1 builds two interceptors, go 1 3 sends the fleet, emp shows the empires, and end and wait 3 finish the turn (or up to three turns). save 1, load 1 and slots work with saves: they stay in the browser.
Try everything from the lesson: save to slot 1, play a little more, load slot 1 and see that you went back. Look at emp: how many points each one has. Does the game make you want a different price for a technology? That is balance.
The idea
Run it on your computer
The whole file is here: [Cosmos.java](/games/cosmos/en/Cosmos.java), [Cosmos.cs](/games/cosmos/en/Cosmos.cs), [cosmos.py](/games/cosmos/en/cosmos.py), [cosmos.js](/games/cosmos/en/cosmos.js). Put it in an empty folder and run it:
node cosmos.jsWith --plain it prints the frames as plain text, with --seed=42 the galaxy is always the same, with --lang=de it is in German (there are en, de and es). The simulation you saw is --simulate=200 (with --seed=7 it gives the same table). Saves are files in a saves folder next to the game, and --memory-saves keeps them in memory.
import), so if Node complains, rename it to cosmos.mjs.The game is written four times with the same names and gives byte-for-byte the same output on every language. The check on the site runs every version with the recorded playthrough (192 commands) and with the simulation and compares them with one file. So you can read the game in the language you know and be sure it is the same one.
The idea
Ideas to continue
The game is yours: you have all the code. Here are things that are exactly the right size, and each one uses something from this lesson.
- A diplomatic victory. A new check in
checkVictory: peace with every living empire for 20 turns in a row. Where in the order should it go? Before or after the economy? Add it and run the simulation to see how many games end this way. - Autosave. On every
end, save to slot 0. What happens if the game crashes in the middle of a save? (Hint: write to a temporary file and rename it.) - A new save version. Add a key (say
difficulty) and raise the version to 2. Loading should also read old version 1, filling in a default value. That is how every game moves from version to version. - A game as a list of commands. The seed and the list of commands are the whole game: that is the very playthrough you use to check the game. Build
replay <file>. - Balance to your taste. Run
--simulate=1000with different numbers for technology price and income and write up in a table what is fairest. - Another galaxy. Maps from a file instead of from a seed, or a bigger galaxy (the lanes at scale, the graph from the first lesson).
- Player against player over the network. The commands are already text, one line each: moves can travel over a socket. The game does not change, only where the line comes from.
- A new language. Port the game to Go, Rust or Kotlin and run the same playthrough: the output must be the same file.
Try it
Predict the output
What does the program print?
function checksum(text) { let sum = 0; for (let i = 0; i < text.length; i++) { sum = (sum * 31 + text.charCodeAt(i)) % 1000000007; } return sum;}
console.log(checksum("ab") + ", " + checksum("ba") + ", " + checksum("abc"));“ab” is 97 × 31 + 98 = 3105, “ba” is 98 × 31 + 97 = 3135, and “abc” is 3105 × 31 + 99 = 96354. The second option is for a checksum that does not tell order apart (but this one multiplies by 31, so “ab” and “ba” differ). The third is the sum of the codes (195, 195, 294): that is what you would get without the multiplication by 31.
Try it
Find the bug
A colleague decided to “fix” one line in bestScoring. The two empires have equal points, and the program prints that the later one wins (Orion Hegemony). The rule is that the earlier one wins. Which line is to blame?
Pick the line with the bug.
>= instead of >: on a tie the new one becomes the leader, so the last of the tied wins, not the first. With > the comparison is strict and a tie does not displace the leader. The bug does not show with different points, which is why it is hard to catch: it appears only on a tie.
Exercise
A checksum for every line
Solve the task in your editor.
A checksum for every line. The program reads lines until the end of the input and, for every non-empty line, prints the line, a space and its checksum (by the formula from the lesson, remainder modulo 1000000007).
With the input turn=12 | turn=13 | seed=2026 | COSMOS (one per line) the output is:
turn=12 408655515turn=13 408655516seed=2026 801283199COSMOS 993660451Check with “ab” and “ba”: 3105 and 3135. Watch the intermediate result sum * 31: in Python and JavaScript compute it without overflow, but the formula stays the same.
More tasks
- A new victory. Add a diplomatic victory to the
victoryprogram (peace with every living empire for 20 turns in a row). Where in the order should it be? - Version 2. Add a
difficultykey to the save and raiseVERSIONto 2. Letloadalso accept version 1 with a default value of 1. What happens to the checksum of an old save? - Even balance. In the
balanceprogram pick the builder’s numbers (income and spread) so that all three characters win between 30% and 36% of the games, while the average income stays 30. - Catching a second entry. What happens if two lines in a save have the same key? Read
SaveReader: which value stays? Write a check that rejects it.
Test
Test yourself
Related lessons
Section titled "Related lessons"- Builds on: Exceptions · Throwing and your own exceptions · Arrays: push, splice and sort · Map and Set
- Tasks on this topic: Epic Cosmos: the empire