01Nine roles, one struct
There is no AI class. There is a container holding nine of them.
model/ai/AI.java is a plain final struct: nine fields, one per role, assigned once in the constructor and never reassigned. There is no dispatch layer, no strategy object, no shared blackboard. An empire's AI is the tuple of which nine implementations it happens to hold.
| Interface | Owns | Called |
|---|---|---|
Scientist | Splitting research across the six categories, and valuing every individual tech. ~48 overloads of baseValue(TechXxx). | Once per turn, first |
SpyMaster | Internal security level, per-empire spy budget, mission type, sabotage target, who to frame. | Twice: security first, per-view later |
Governor | Each colony's five spending sliders, and the empire tax rate. | Once per colony, last |
ShipDesigner | The six design slots: what to draw, when to obsolete, when to scrap. | Once per turn |
FleetCommander | Building and filling fleet plans; moving ships; scouts and colonisers. | Once per turn |
General | Per-system war assessment: what to guard, what to invade, what to bomb. | By the FleetCommander, at the end of its turn |
Treasurer | The reserve: who gets a cash transfer and from whom it is collected. | Once per turn |
Diplomat | Treaties, war and peace, incident interpretation, council voting, tech trading. | In a different turn phase entirely |
ShipCaptain | Tactical combat: target selection, movement, retreat. | Inside combat, off the main sequence |
The structurally important fact is what isn't there:
base, modnar, rookie and xilmi are four independent copies of the same nine classes. Not one of them extends another's implementation — every class is a fresh implements Base, <Role>. "Falling back to base" happens only in the AI.java switch, by literally naming another package's class. A bug in the shared logic has to be fixed four times, and several in §15 were fixed in some copies and not others.
One oddity: interfaces/ShipTemplate.java is not an interface. It is an abstract class living in a package called interfaces, and it is the only file there that isn't a contract.
02The flavours
Eight selectable AIs. Five of them differ only in the diplomat.
The constructor is one big switch over an integer id, and reading the whole table at once is the fastest way to understand what the AI options actually mean:
| AI | General · Captain · Governor · Designer · Scientist · Fleet · Treasurer | Diplomat | SpyMaster |
|---|---|---|---|
| Base (hidden) | base | base | base |
| Modnar (hidden) | modnar | modnar | modnar |
| Rookie | rookie | rookie | rookie |
| Xilmi (default) | xilmi | xilmi | xilmi |
| Hybrid | xilmi | rookie | rookie |
| Fun | xilmi | fun | xilmi |
| Character | xilmi | fusion, variant 1 | xilmi |
| Fusion (also the default arm) | xilmi | fusion | xilmi |
| Player (not selectable) | xilmi general/captain/designer/scientist · player governor/fleet/treasurer | modnar | player |
So the five "advanced" options — Xilmi, Hybrid, Fun, Character, Fusion — run the same general, the same combat AI, the same governor, the same ship designer and the same scientist. What you are choosing between is a diplomat. And the fun and fusion packages contain only AIDiplomat.java; they are not AI flavours in any structural sense, just alternate diplomacy modules bolted onto the xilmi stack.
"Character" is the narrowest of all — it is Fusion constructed with variant = 1, and that flag is read in exactly two lines of xilmi code: one in the Scientist's research priorities, one in the General's coloniser count.
optionalAIset() returns an empty list unless the showAllAI option is enabled. Since that list feeds every selection menu, out of the box you cannot pick Base or Modnar as opponents at all — the two packages carrying Ray Fowler's and modnar's original code are invisible. Yet the human player's own slot still hard-wires modnar.AIDiplomat, so modnar code runs for the player even when modnar is hidden.
The player slot is the most revealing entry. When autoplay is off, the human empire gets AI id PLAYER: xilmi's general, captain, ship designer and scientist (which fill in designs and research when you don't), the player package's governor, fleet automation, spy sliders and budget autospend — and a modnar diplomat that a human never uses, present only so the interface's helper predicates resolve. The comment above it reads // TODO BR: progressively move to player "AI".
03The turn order
Nine calls, in a fixed sequence, with three of the roles reached indirectly.
public void makeNextTurnDecisions() {
recalcPlanetaryProduction();
shipLab.nextTurn();
if (isAIControlled()) {
scientistAI().setTechTreeAllocations(); // 1
securityAllocation = spyMasterAI().suggestedInternalSecurityLevel(); // 2
empireTaxLevel = governorAI().suggestedEmpireTaxLevel(); // 3
shipDesignerAI().nextTurn(); // 4
fleetCommanderAI().nextTurn(); // 5 -> General
ai().sendTransports(); // 6
}
if (isAIControlled()) {
ai().treasurer().allocateReserve(this); // 7
for (EmpireView ev : empireViews()) // 8 -> SpyMaster
if ((ev != null) && ev.embassy().contact())
ev.setSuggestedAllocations();
}
...
for (int n=0; n<sv.count(); n++) // 9
if (sv.empId(n) == id) ... governorAI().setColonyAllocations(sv.colony(n));
}
Three indirections matter for reading the code. The General is never called from Empire — the FleetCommander calls it as the last line of its own turn. The Diplomat runs in the assessTurn phase, not here. And the Scientist runs first, so the war state it reads was set by the Diplomat on the previous turn.
04Personality and objective
Two orthogonal six-way traits — and only two roles read them seriously.
Every empire's Leader carries one personality (Erratic, Pacifist, Honorable, Ruthless, Aggressive, Xenophobic) and one objective (Militarist, Ecologist, Diplomat, Industrialist, Expansionist, Technologist). Both are drawn from a race-weighted roulette defined in each race's definition.txt, unless the "randomize AI personality" option is on, in which case both are uniform random.
| Race | Personality weights err / pac / hon / rut / agg / xen | Objective weights mil / eco / dip / ind / exp / tec |
|---|---|---|
| Human | 20, 20, 60, 0, 0, 0 | 10, 10, 50, 10, 10, 10 |
| Alkari | 20, 20, 60, 0, 0, 0 | 50, 10, 10, 10, 10, 10 |
| Mrrshan | 0, 0, 0, 60, 20, 20 | 50, 10, 10, 10, 10, 10 |
| Psilon | 20, 60, 20, 0, 0, 0 | 10, 10, 10, 10, 10, 50 |
| Bulrathi | 20, 0, 0, 20, 60, 0 | 10, 50, 10, 10, 10, 10 |
| Darlok | 20, 0, 0, 20, 60, 0 | 10, 10, 50, 10, 10, 10 |
| Sakkra | 20, 0, 0, 20, 60, 0 | 10, 10, 10, 10, 50, 10 |
| Klackon | 0, 0, 0, 20, 20, 60 | 10, 10, 10, 50, 10, 10 |
| Silicoid | 0, 0, 0, 20, 20, 60 | 10, 10, 10, 10, 50, 10 |
| Meklar | 60, 0, 20, 20, 0, 0 | 10, 10, 10, 50, 10, 10 |
The weights are a 60/20/20 or 60/20/20/... shape almost everywhere, so each race has a strong tendency with real variance — a Mrrshan is usually Ruthless but is sometimes Xenophobic, and never Pacifist. Only the Meklar lead with Erratic.
Where these traits are actually read is the surprising part. Counting leader().isXxx() call sites across the whole tree:
And the default AI narrows it further: xilmi's Scientist reads personality in exactly one place, a six-way objective block, versus base's eighty. So on the shipped default, an empire's character shows up almost entirely in its diplomacy, and barely at all in how it plays.
05The Scientist
Sixty ticks, split by objective, then every tech priced by value over cost1.3.
The category split is a static table keyed on leader objective, out of a fixed 60-tick budget:
| Objective | Comp | Cons | Force | Plan | Prop | Weap |
|---|---|---|---|---|---|---|
| Diplomat | 9 | 9 | 12 | 9 | 11 | 10 |
| Militarist | 11 | 9 | 10 | 8 | 10 | 12 |
| Ecologist | 10 | 10 | 10 | 12 | 9 | 9 |
| Industrialist | 10 | 12 | 11 | 9 | 9 | 9 |
| Expansionist | 10 | 9 | 9 | 10 | 12 | 10 |
| Technologist | 10 | 10 | 10 | 10 | 10 | 10 |
| Human player | 10 | 10 | 10 | 10 | 10 | 10 |
The spread is deliberately narrow — the strongest preference is 12 vs 8, a 50% tilt, not a specialisation. Note that Technologist is identical to the flat default: the "research-focused" objective has no research preference at all, which is coherent (it wants everything) but means one of the six objectives is a no-op in this table.
Two mode overrides then apply, and only one of them at a time (else if):
if (empire.generalAI().inWarMode()) {
computer +4 construction -3
forceField +1 planetology -3
propulsion -3 weapon +4
}
else if (empire.fleetCommanderAI().inExpansionMode()) {
computer +1 construction -3
forceField -3 planetology +4
propulsion +4 weapon -3
}
Both deltas sum to zero, preserving the 60 total. War mode is the one-boolean signal from the General (numEnemies() > 0, which includes war preparations). Expansion mode is even simpler: empire.tech().shipRangeLevel() < 6.
Picking the tech inside a category
public float researchPriority(Tech t) {
// by raising tech cost to 1.3, we will tend to value researching lower-cost
// techs that have a similar value/cost ratio as more expensive ones
// iow, for each 10x in cost, there needs to be 20x value to be same priority
float costExp = 1.3f;
return researchValue(t)/(float)Math.pow(t.researchCost(), costExp);
}
That exponent is the single most consequential number in the file. At 1.0 the AI would be indifferent between cheap and expensive techs of equal value density; at 1.3 it needs 20× the value for 10× the cost, which biases it hard toward finishing cheap techs. This is why ROTP AIs sweep the low tiers of a category rather than beelining a single expensive prize.
researchValue then multiplies baseValue by a war or expansion factor: 3× for Armor, Battle Suit, Combat Transporter, Hand Weapon and Personal Shield; 2× for battle computers, bio-weapons, bombs, deflector shields, missiles, planetary shields, repulsors, ship weapons, stream projectors and torpedoes; 1.5× for most of the specials. Expansion overrides exist for only three techs — Control Environment (3), Fuel Range (3), Engine Warp (2).
The ~48 baseValue overloads follow one idiom throughout: scale so the best tech in this line is worth about 50, apply personality multipliers, then multiply combat techs by sqrt(numEnemies + 1). So a Ruthless Militarist at war with three empires values a bomb upgrade at roughly six times what a peaceful Ecologist does — from the same table, with no special-casing.
setTechTreeAllocations() only calls the objective table when all six or none of the categories are researching Future Tech. In the mixed state — which is where every long game ends up — it discards personality, war mode and expansion mode entirely and splits 60 / (6 − futureTechs) flat across the remaining categories. The AI's research character switches off partway through the late game.
06The General
In the base AI it has no model of military power at all.
This is the biggest surprise in the whole subsystem. The General interface declares about thirty methods — bestVictim(), biggestThreat(), defenseRatio(), timeToKill(), warROI(), smartPowerLevel(), militaryRank() — and every one of them is a default explicitly commented "specific to Xilmi AI". The base General implements none of them, so it inherits constants:
default Empire bestVictim() { return null; }
default Empire biggestThreat() { return null; }
default float defenseRatio() { return 0.5f; }
default boolean strongEnoughToAttack(){ return true; }
For the base AI, defenseRatio() is always 0.5, there is never a biggest threat, and it is always strong enough to attack. Its entire war-state model is one line:
public boolean inWarMode() { return empire.numEnemies() > 0; }
And its whole turn is two cached scalars plus one pass over every system in the galaxy, stamping each with a fleet plan. There is no enemy ranking, no target-empire selection, no threat aggregation — it is purely system-by-system.
What a planet is worth
// (4*pow(SIZE, 0.7) + min(20, sqrt(FACTORIES))) // Normal, size-100, 0 factories: val = 100 // Normal, size-100, 200 factories: val = 115 // Artifact, size-80, 240 factories: val = 203 float val = (float)(4.0f*Math.pow(size, 0.7f) + Math.min(20.0f, Math.sqrt(fact))); if (isUltraPoor) val *= 0.6; else if (isPoor) val *= 0.75; else if (isRich) val *= 2; else if (isUltraRich) val *= 3; if (isArtifact) val *= 2; else if (isOrionArtifact) val *= 3; ... return val/115; // normalised to a size-100 planet with 200 factories
The min(20, sqrt(factories)) cap is the interesting shape: factories contribute at most 20 points against a size term that runs to 100+, and they saturate at 400 factories. The AI values planets by size, not by industry — a huge undeveloped world outranks a small industrial one, which is the right instinct in a game where population is the council vote.
There is also a separate invasionPriority() on a completely different scale (size + factories/20, resource ×2 to ×5, divided by sqrt(distance) and sqrt(bases)) — and the two scoring functions disagree about poor worlds: takePlanetValue penalises them, invasionPriority gives Poor ×2 while omitting Ultra-Poor entirely, so an ultra-poor world falls through at ×1 and ranks worse than a poor one.
The sneak attack
The one place the General starts a war itself. Pacifists and Honorables never do it; nobody does it with more than two existing enemies. Then a score is assembled — 0.3 − 0.3×numEnemies, +0.6 Aggressive, +0.3 Ruthless, −0.2 Diplomat, −0.3 if the planet is uncolonisable, scaled down below tech level 10, adjusted by how many enemies the target already has — and compared against a fixed 0.5.
Every variable in that function is named baseChance, bonus, enemyMod — and there is no random() anywhere in it. The comparison against 0.5 is fully deterministic. With exactly one enemy the base value is 0.0, so only Aggressive or Ruthless leaders can ever clear the bar.
07The Diplomat
Four routes to war, and a 4:1 advantage before opportunism.
The council vote and the incident ledger are covered in the Diplomacy and Council documents. What matters here is how the AI decides to fight. decidedToDeclareWar() tries four routes in order:
- Incident war — any new incident whose
triggersWar()is true, or whose warning timer has expired. - Erratic war — a flat 2% per turn if the leader is Erratic. "These guys are crazy," says the comment.
- War of hate — relations below a personality threshold.
- War of opportunity — a power calculation.
War of hate starts from a threshold between −70 and −90 set by leader personality, then adjusts: +10 × (their enemies − my enemies), +30 if they are allied with an enemy of mine, and +10 × scaleOfContempt (which is +1 per doubling of my power advantage). So a much stronger empire will go to war over a grievance a weaker one would swallow.
War of opportunity is the closest thing base has to a military model:
// don't declare if we have no spy data or data is too old int reportAge = v.spies().reportAge(); if ((reportAge < 0) || (reportAge > 10)) return false; // base power keeps ratios from wildly fluctuating early in the game // ... so that wars aren't triggered because I have 4 fighters and you have 1 int basePower = 200; float otherPower = basePower + v.status().lastViewValue(empire, FLEET) * v.spies().tech().avgTechLevel(); float myPower = basePower + v.owner().totalFleetSize() * empire.tech().avgTechLevel(); float baseThreshold = v.owner().atWar() ? 8.0f : 4.0f; float treatyMod = v.embassy().pact() || v.embassy().alliance() ? 1.5f : 1.0f; float techMod = myTechLvl < 8.0f ? 8.0f / myTechLvl : 1.0f; float enemyMod = (float)(empire.numEnemies() + 1) / (v.numEnemies() + 1); float warThreshold = baseThreshold * techMod * enemyMod * treatyMod * leaderExploitWeakerEmpiresRatio(); return (myPower/otherPower) > warThreshold;
Three things to notice. The AI needs a 4:1 fleet×tech advantage in peacetime and 8:1 if already at war — a genuinely high bar that keeps opportunistic wars rare. It refuses to act on spy data older than ten turns, which is one of the few places any role is explicitly limited by information age. And the basePower = 200 floor exists purely to stop the early game degenerating into wars over a three-ship advantage.
Crucially, none of the four routes declares war immediately. All of them call beginWarPreparations(); only an incident that triggersImmediateWar() skips it. What the AI normally enters is a war footing — and that is what makes isEnemy() true, which is what flips inWarMode(), which is what re-weights the Scientist. One diplomatic decision propagates through the whole AI via a boolean.
War weariness
Whether the AI will take peace comes down to how much it has lost against how much it was willing to lose, keyed on the leader:
| Limit | Values | Keyed on |
|---|---|---|
| Population | Pacifist .8 · Ruthless .4 · everyone else .6 | personality |
| Factories | Industrialist .8 · Ecologist .4 · everyone else .6 | objective |
| Fleet size | Militarist .5 · everyone else .3 | objective |
| Colonies | Ecologist .8 · Expansionist .8 · everyone else .6 | objective — but dead, see §15 |
The limit is the fraction you are willing to keep, so a Pacifist quits at 20% population loss and a Ruthless holds on through 60%. Each ratio is checked individually, and the three live ones are also summed — exceeding 2 in total is enough on its own.
08The Governor
A strict priority ladder over 50 ticks, with an early return at every rung.
Colony spending is 50 ticks, one tick = 2%. Three override modes short-circuit everything: a colony with a research project (plague, supernova) goes 100% research; a colony on the General's rush-defense list goes 100% defense; a colony on the rush-ship list goes 100% ship. Otherwise:
| # | Category | Gets |
|---|---|---|
| 1 | Ecology (minimum) | Exactly the minimum waste cleanup cost. Always first, always paid. |
| 2 | Ship | Only if the yard isn't building an obsolete design, wants ships, and 1/shipTurns > factoryIncreasePct. |
| 3 | Industry | 100% of what remains, up to what the factories can absorb. |
| 4 | Ecology (rest) | 40% of what remains after that — terraforming, growth, shields. |
| 5 | Defense | 30% of what remains after that. |
| 6 | Research | Everything left over. |
Between every rung sits if (col.totalAmountAllocated() >= maxAllocation) return; — so once 50 ticks are consumed, everything below stays at zero. On a young colony that means industry eats the entire budget and research gets nothing at all until the factories are saturated. That is the ROTP AI's whole economic doctrine, in six lines: clean, then build, then grow, then defend, then think.
The ship decision is worth reading closely, because it is the one genuine trade-off in the function:
if (!col.shipyard().buildingObsoleteDesign()
&& (col.shipyard().desiredShips() > 0)
&& ((1.0/shipTurns) > factoryIncreasePct)) { ... }
Read plainly: build ships only if the fraction of a ship you'd finish this turn exceeds the fractional factory growth you'd get instead. It compares two rates of return directly, and it is why an AI with a half-built industrial base almost never builds warships.
Missile bases scale with production and threat: production/30 on an attack target, /40 on a border system, /50 in the interior — and zero if the empire has no contacts at all.
The human player's per-colony governor checkbox is resolved outside the AI, in Empire: an AI empire always runs AIGovernor, while a player colony runs it only when the checkbox is off (or a ship-limit / stargate override fires).
09The Ship Designer
Five roles into six slots, with a per-role decay timer.
Every turn the designer refreshes five roles in a deliberate order — fighter, destroyer, bomber, colony, scout — "in order to prioritize use of empty design slots." Each role carries an obsolescence grace period:
Because there are only six slots, replacement is a real decision. The ladder is: skip entirely if there's no faster engine and the hull is already packed; replace in place if already obsolete and out of grace; do nothing if the design already matches; replace in place if hardly any are flying (total build value ≤ 100 BC for fighters and bombers, ≤ 1000 for destroyers); otherwise score the upgrade.
float dmgRatio = newDesign.perTurnDamage() / currDesign.perTurnDamage(); float engineImprv = (newDesign.engine().warp()+1) / currDesign.engine().warp(); float upgradeThreshold = empire.atWar() ? 2 : 1.5f; float upgradeChance = dmgRatio * engineImprv; if (upgradeChance < upgradeThreshold) return;
Note the direction: the bar is higher at war (2× rather than 1.5×). An empire in a shooting war is deliberately more conservative about redesigning, because obsoleting a design means the ships already in the field stop being reinforced.
How a design gets drawn
The template builds one candidate at each of the four hull sizes plus the incumbent, then picks by total damage per unit of budget — count × perTurnDamage where count is how many that budget affords. Within a hull, engine and computer are fitted first, then the remaining space is split by weight between shield, ECM, manoeuvre, armour and specials, with the weights differing by role and specials getting a bonus on Large and Huge hulls. Weapons take everything left.
Weapon choice is a minimax against the three worst-relations rivals: for each candidate weapon, simulate damage against each rival's known tech, keep the minimum, and pick the weapon whose worst case is best. Any weapon class already installed is skipped, which is why AI designs reliably carry a mix of beam, missile and streaming weapons rather than four copies of the best gun.
10The Ship Captain
It targets by danger, not by value — and squares the danger.
A combat turn is: check retreat, then loop { pick a target, path toward its optimal firing range, fire if in range } until neither the target nor the remaining movement changes. Movement and firing are not separate decisions; the stack always closes to range first.
Target selection is one scoring function:
float killPct = max(stack.estimatedKillPct(target, false), expectedPopLossPct(stack, target));
CombatStack ward = stack.hasWard() ? stack.ward() : stack;
int distAfterMove = target.canTeleport() ? 1 : (int) max(1, target.movePointsTo(ward) - target.maxMove());
...
float rangeAdj = 10.0f/distAfterMove;
threatLevel = rangeAdj * target.estimatedKillPct(ward, false);
...
float desirability = max((10000 * threatLevel * threatLevel * killPct), .01f);
// then divided by turnsToReachTarget
Three properties follow directly. The AI shoots at what threatens it, not what it wants dead — value never enters. The score is quadratic in threat and only linear in kill fraction, so a slightly scarier target beats a far more killable one; a nearly-dead battleship outranks a fresh scout by a wide margin. And threat scales as 10/distance, so a dangerous stack that is about to arrive is worth ten times one that is ten squares out.
There is one hard override. If the stack is guarding a colony and a target can reach bombing range next turn and would do more than 5% damage, the search is restarted — bestTarget = null; maxDesirability = -1; — and only bombers in range are considered from then on. Colony defence pre-empts everything.
Retreat
The checks run in order: never retreat while your own missiles are still in flight; never retreat while your ward is still in the fight; always retreat if out of ammo; never retreat if nothing present can even target you. Then the force comparison — for each side, sum each stack's best available kill value against the other side — and:
float retreatRatio = stack.empire().diplomatAI().leaderRetreatRatio(combat().system().empire()); return ((enemyKills / allyKills) > retreatRatio/1.2f);
leaderRetreatRatio is 1.5 by default, ×4 if honourable and defending an ally, ×2 if aggressive, ÷2 if pacifist. Divided by 1.2, a default leader breaks off when the enemy's best-case kill value exceeds 1.25× its own — a fairly quick trigger, which is why ROTP AI fleets disengage more readily than most 4X opponents.
11The thin two
The Treasurer and the SpyMaster are much smaller than their names suggest.
base/AITreasurer.java is 29 lines. All the actual reserve policy lives as a default method on the interface, and it is a fixed priority list: colonies with a research project first (that is the plague and supernova bill), then the General's rush-defense list, then the rush-ship list, then small colonies, then harvest whatever is left back into the reserve. There is no budget-request channel — the General cannot tell the Treasurer to save for a war, and the reserve target never changes with war state.
The SpyMaster is thinner still where it counts. Internal security is a paranoia counter (+3 per war, +2 per no-treaty contact, ×2 if Xenophobic) clamped to 10. The espionage budget is a five-way ladder that barely varies:
if (emb.finalWar()) maxSpiesNeeded = 3; else if (emb.war()) maxSpiesNeeded = 2; else if (emb.noTreaty()) maxSpiesNeeded = 1; else if (emb.pact()) maxSpiesNeeded = 1; else if (emb.atPeace()) maxSpiesNeeded = 1; else if (emb.alliance()) maxSpiesNeeded = 1; if (spies.numActiveSpies() >= maxSpiesNeeded) spies.allocation(0); else spies.allocation(maxSpiesNeeded * 2);
Every peacetime relationship — no treaty, pact, peace, full alliance — is treated identically. The maximum spend against any one empire is 6 ticks, and the realistic steady state is 2. The AI is a very light spy by construction, which is worth knowing if you have been wondering why espionage pressure from opponents feels low.
Mission choice does read personality, and produces one memorable inversion: in the peace-or-pact branch, after Technologists always spy and Pacifists and Honorables always hide, the fallback is relations > 30 → beginEspionage(). The AI spies on the people it likes — presumably because good relations mean low risk, but the effect is that being friendly with an AI is what earns you its spies.
12What difficulty actually does
One multiplier on AI colony output. That is very nearly the whole thing.
| Difficulty | Production × | Waste × | Event delays × |
|---|---|---|---|
| Easiest | 0.55 | 0.55 | 4 |
| Easier (default) | 0.75 | 0.75 | 3 |
| Easy | 0.90 | 0.90 | 2 |
| Normal | 1.00 | 1.00 | 1 |
| Hard | 1.10 | 1.00 | 1 |
| Harder | 1.25 | 1.00 | 1 |
| Hardest | 1.45 | 1.00 | 1 |
| Custom | 0.20 – 5.00 | min(1, custom) | 1 |
The multiplier lands in exactly one place that matters:
float mod = isPlayer ? 1.0f : (opts.aiProductionModifier() * dynaMod); float workerProd = workingPopulation() * empire.workerProductivity(); float factoryOutput = mod * (workerProd + usedFactories()); return factoryOutput - transportCost();
Note that it scales worker output and factory output together — it is the AI's entire BC income, every turn, not a factory bonus. And there is no corresponding research discount anywhere: the AI's tech lead on high difficulty is purely downstream of having more money.
Three asymmetries are worth knowing:
- The waste break only goes one way. Easy AIs pollute less; hard AIs get no pollution penalty. Half of the intended symmetry is simply not implemented.
- Only two packages read their own difficulty.
rookieandmodnarscale their missile-base budget and invasion fleet sizing bysqrt(aiProductionModifier()).xilmi,base,fusion,funandplayernever look at it at all — so on the default AI, difficulty is a pure economic multiplier with no behavioural component whatsoever. A Hardest xilmi opponent is not a smarter opponent; it is the same opponent with 45% more money. - Difficulty also buffs the monsters. Space pirates, guardian pirates and the Orion Guardian all scale off the same modifier, and the Guardian picks up Automated Repair above 1.2 and Advanced Damage Control above 1.4. Those are hostile to the AI too, so raising difficulty is not a pure AI buff.
The one genuine "extra resources" cheat is a separate opt-in called Challenge Mode, off by default: AI homeworlds start with 80 population and 80 factories against the player's 50 and 30, plus four scouts and two colony ships against two and one.
13Where the AI sees too much
Fleet strength is fogged. Planet sizes and enemy tech are not.
EmpireView exposes the problem in two adjacent lines, with the author's own warning attached:
// Dangerous unlimited calls that returns more than the view from the owner public Empire empireUncut() { return empire; } public TechTree spiedTech() { return spies.tech(); } // Should be used instead of public TechTree techUncut() { return empire.tech(); }
The xilmi AI calls techUncut() in five places: to value an invasion by reading the target's real tech tree, to compute missile-base quality when planning an attack, to estimate population regrowth during a siege, and to average opponent tech levels for spy budgeting. The comment recommending spiedTech() went unheeded.
Two of the General's top-level strategic scores go further and enumerate the galaxy directly:
public float totalEmpirePopulationCapacity(Empire emp) {
...
for (int id=0; id<emp.sv.count(); id++) {
StarSystem current = galaxy().system(id);
if (current.colony() == null) continue;
if (current.empId() != emp.id) continue;
capacity += current.planet().currentSize();
}
That walks every system in the galaxy and sums the true planet size of every colony the target owns, including systems the observer has never scouted — and it is the numerator of bestVictim()'s score. The AI chooses who to attack partly on knowledge it should not have. defenseRatio() is the same shape: it reads the enemy's actual shield levels, populations and budget split rather than any spy estimate.
The engine does fog fleet strength honestly — totalFleetSize(Empire) returns the maximum of the spy report and currently-visible ships, and someone deliberately commented out the spy-estimate path in militaryPowerLevel rather than leaving it reading the truth. Scouting gates are respected for target selection (if (!empire.sv.isScouted(sysId)) return 0.0f;), and the fusion and fun diplomats carry an explicit comment: "I mustn't cheat, even if it helps to make it more fair. I can only take into account what I know." The omniscience is concentrated in xilmi's strategic scoring, not spread everywhere.
14The verdict
The right decomposition, undermined by almost no communication between the parts.
What works
The nine-role split is genuinely good architecture for a 4X. Each role owns one decision with a clear input and a clear output, and each can be swapped independently — the flavour table proves it in the most direct way possible, by shipping five "different AIs" that are the same seven roles with a different diplomat. It also means a modder can rewrite one brain without understanding the other eight, which is exactly what happened: xilmi replaced the strategic layer and left the ship templates byte-identical to the original.
The individual scoring functions are also better than they need to be. value / cost1.3 is a real insight about research pacing. The minimax weapon selection produces mixed-armament designs without any rule saying "carry a mix". The colony ladder compares two rates of return directly rather than using a fixed split. None of these are complicated, and all of them produce recognisably sensible behaviour.
What doesn't
The coupling is the ceiling. There are roughly ten cross-role calls in the entire base package, and the richest signal in the system is inWarMode() — a boolean derived from numEnemies() > 0. The Scientist knows a war is happening but not who with, what they have, or what the General intends to do about it; it ends up doing its own enemy sensing by walking empireViews() inside individual baseValue methods rather than asking. The General cannot ask the Treasurer to save for an offensive. The Diplomat cannot tell the General who it is about to fight. Every genuinely strategic behaviour — build up, then strike — has to emerge from independent local decisions, and mostly doesn't.
The second cost is the copy-paste. Four independent forks of nine classes means every fix is four fixes, and §15 is largely a catalogue of what happens when it is only one or two. Several of the dead formulas below — integer division where a float was intended — are duplicated verbatim across base, modnar and rookie.
Because the default AI never reads its own difficulty setting, the entire difficulty ladder is an economy multiplier. There is no "the AI plays more carefully on Hard" — there is only "the AI has 45% more money on Hardest." That is a legitimate design choice, and it is at least honest about being one, but it means the game's hardest setting exercises none of the AI's decision-making differently than its easiest.
15Quirks, dead code, and things that look wrong
Verified against the source, not inferred. Roughly ordered by impact.
| What | Detail |
|---|---|
| War restraint is dead, twice | if (empire.numEnemies()/(1+Math.abs(empire.numEnemies())) > random()) return false; — numEnemies() is an int, so n/(1+n) is integer division and is 0 for every n. The comment says "asymptotic x/(1+abs(x))"; the float was never written. Appears in both decidedToDeclareWar and wantToDeclareWarOfOpportunity, so the base AI has no restraint on multi-front wars at all. |
| Four war-weariness checks neutered by a cast | if (treaty.colonyChange(emp.id) < (int)Math.min(0.85, enemyMod*limit)) — those *Change methods return ratios, and (int) truncates 0.85 / 0.6 / 0.4 / 0.3 to 0. All four reduce to "ratio < 0", never true for a live empire. Consequence: the whole warColonyLossLimit table is dead — colonies lost never contribute to war weariness — and enemyMod is entirely unused. |
| The combat risk map is a zero generator | generateRiskMap() runs before every stack turn, filling a double[width][height] from riskAt(), which is return 0.0d;. The array appears in exactly three places in the source and is never read. It is a full O(w×h) loop per stack per turn writing zeroes. |
| Xilmi's first-strike check compares own stacks to own stacks | In DistanceToBeAt, both nested loops filter if (x.empire() != empire) continue; — the outer one is named enemy but keeps only friendly stacks. The range test is also inverted (it sets "can attack me" when the condition is *cannot reach*), and the break only escapes the inner loop. |
| Every bomb tech scores identically | baseValue(TechBombWeapon t) computes 0.71f * curr.damageHigh() — using the bomb we already have, never reading t. All candidates tie, so the comparator falls through to the level tie-break and the AI always researches the lowest-level bomb available. |
| Teleporter can never be researched in peacetime | allEnemiesHaveInterdiction is initialised true and only ever set false — so the variable means the opposite of its name, and with zero wars the loop never runs, the flag stays true, and the early return 0 fires. Its mirror, Subspace Interdictor, uses the correct pattern. |
| The AI is scored with a special it never installs | In simulateDamage, when a range special improves damage the code writes spec.special = null; instead of spec.special = sp; — the sibling simulateBombDamage gets it right. Every anti-ship design is rated as if it had a cloak or teleporter and then never receives one. |
| Design slot 0 can't be claimed | All three warship-replacement paths do if (slot > 0), but availableDesignSlot() legitimately returns 0. If slot 0 is the only free one, the new design is discarded — after the current design has already been marked obsolete, so it rots for 12–20 turns with no replacement. |
| Integer division in the designer's upgrade score | computerAdj = (newDesign.computer().level()+1) / (currDesign.computer().level()+1) — both level() methods return int, so the division truncates before widening to float. Mark 3 over Mark 2 gives 1, not 1.33, and any non-improvement gives 0, which zeroes the whole upgrade score. |
| Killer-instinct multipliers are step functions | val *= ((remainingSystems + 7)/(remainingSystems + 1)); in both takePlanetValue and invasionPriority — integer division again, so the intended smooth 2.2 / 2.5 / 3.0 / 4.0 curve becomes 2 / 2 / 3 / 4. |
| Atmosphere Enrichment is worth 0 or 50, nothing between | float pctImproved = possible.size()/empire.allColonizedSystems().size(); — int/int, so it is 0 unless every colonised system is hostile, in which case 1. |
| "Pick the worst incident" picks the last one | worstNewSeverity is initialised to 0 and never assigned, so the loop that is supposed to find the most severe war-triggering incident just keeps the last one with negative severity. The sibling method worstWarnableIncident has the same bug and is never called at all. |
| Hate threshold reads the wrong empire | v.diplomatAI().leaderHateWarThreshold() — EmpireView.diplomatAI() returns the viewed empire's AI, so my willingness to go to war out of hatred is set by my target's personality. Everywhere else in the same file uses v.owner().diplomatAI(). |
| Peace acceptance ignores its own leader modifiers | leaderAcceptPeaceTreatyMod() and leaderContemptAcceptPeaceMod() are implemented on every diplomat and never called anywhere. Peace acceptance reduces to random() <= diplomacyBonus/200 || warWeary(v). Six of the seventeen leader* bridge methods are dead end-to-end, taking the entire 56-line personality affinity matrix with them. |
| The governor's reserve block is a guaranteed no-op | final int rsvAmt = (int) Math.min(0.0, col.pct(RESEARCH) - 0.5); guarded by col.pct(RESEARCH) > 0.5. The argument is always positive, so Math.min(0.0, positive) is 0. Should have been Math.max. |
rushShipSystems is write-only in base |
Declared, exposed, cleared every turn — and never added to anywhere in the package. Two consumers read it: the Governor's 100%-to-ships override and the Treasurer's third reserve priority. Both are dead. |
| The invasion troop cap compares the wrong units | if (value > options().maxLandingTroops(...)) where value is the normalised planet desirability (~1.1) and the right-hand side is a troop count. The guard never fires. (The maxLandingTroops switch itself has no break statements, so every case falls through to Integer.MAX_VALUE anyway — the whole option is inert.) |
| Xilmi's invasion prediction is computed and discarded | About thirty lines model bombardment attrition, natural growth and defender reinforcement to derive expectedTargetPopulation — and then the returned troop count is empire.sv.population(id) * killRatio, identical to base. The variable is never read. |
Five General interface methods have no implementation anywhere |
isTrader(), isSpy(), isExpander(), isInvader(), isRusher() — zero call sites and zero overrides in any package. |
A type-sniff standing in for instanceof |
// Xilmi: Not a nice way, but a way to tell Xilmi-AIs apart from base-AIs: followed by if (empire.generalAI().absolution() != 0). The same trick is used in six incident classes. |
| Byte-identical forks | After normalising the package line, ShipBomberTemplate and ShipFighterTemplate are identical across base, modnar, rookie and xilmi — four copies each. rookie's FleetCommander, ShipDesigner and Treasurer are literally modnar's, so "Rookie" is not a distinct fleet or design AI at all. |
| Stale comments contradicting the code | The difficulty table's own comment lists seven multipliers, none of which the switch returns. selectedOpponentAIOption() carries // modnar: default to modnar AI beside a xilmi default. The SpyMaster's security comment says "0 to 40" against a clamp of 10, and its budget comment says "max 10% of production" for a value that works out to 12.5%. |