24 KiB
Civilization — Barbarians build plan
Roaming hostile raiders for the early game: an opponent that cannot be bargained with, that forces both the human and the AI to garrison cities and spend shields on defense before they'd like to. Fades out as the world modernises.
Scope decisions (settled with the owner, 2026-08-01):
- No sea raiders. Land uprisings only. No barbarian galleys, no amphibious landings, no naval pathing. If it's ever wanted it's a clean phase 2.
- Barbarian Leader + ransom is in, including a "draw out the leader" hook so killing hordes is what summons one.
- Activity folds into difficulty. No setup-screen dropdown.
- Pressure decays over the game and hard-stops before modern units.
1. Architecture: barbarians are a civ
state.civs already drives everything generic in this engine —
beginCivTurn/endCivTurn cycle by index, runToHumanTurn's stepCiv
animates any civ's moves and combats, resolveAttack / captureCity /
pickDefender / fog / isUnitVisibleTo are all civ-index based, and drawUnit
identifies owners by a colored ring off civ.color.
So barbarians are a real civ appended at the end of state.civs, with
civ.barbarian === true, and state.barbarianIndex recording its index.
Why appended last rather than inserted:
- Every existing civ index stays valid, including
humanIndex. endCivTurn's wrap (if (next === 0) state.turn += 1) still increments the game turn in the right place — barbarians move last in the round.- Old saves have
state.barbarianIndex === undefined→ no barbarians, no migration step, anddeserialize'sversion !== 1gate stays untouched.
The barbarian civ is built by a createBarbarianCiv() in the new module and
pushed by createGame after the normal civ loop, only when
rules.barbarians.enabled && difficulty.barbarianActivity > 0. It gets:
{ id: n, barbarian: true, human: false, alive: true, name: 'Barbarians',
color: rules.barbarianColor /* '#3f1d1d' — outside playerColors */,
government: 'despotism', gold: 0, known: {}, futureCount: 0,
relations: { every other civ: 'war' }, attitude: { …: -100 },
spaceship: {…zeroes}, nameCursor: 0, nameOrder: [], score: 0,
barbKills: {}, // per-civ tally driving leader summons
leaderUnitId: null, leaderExpires: 0, nextSpawnTurn: {} }
It also gets its own state.explored array (pushed for shape parity — every
explored[civ] consumer indexes by civ id). It is never written to; barbarians
see the world by fiat inside their own AI.
civ.alive stays true forever, even at zero units. Uprisings are events, not
a standing empire, so "no units" is the normal state between raids.
1.1 Guard sites — every place that assumes "civ = diplomatic player"
Found by grepping state.civs across all 13 files. Each needs a barbarian skip:
| File / function | Why |
|---|---|
Logic.checkVictory |
living.length === 1 would never fire → conquest victory becomes unreachable. Also its "no cities + no settlers → eliminateCiv" rule would kill the barbarian civ the moment its last raider dies. Both need the skip. |
Logic.contactSweep, Logic.makeContacts |
No contact, ever. Relations are pinned 'war'. |
Logic.updateAttitudes |
See §1.2 — this one is a trap. |
Logic.civScore / Screens.js:765 scores table |
Barbarians don't score and don't appear in rankings. |
Screens.js:147 (diplomacy contacts), :719 (rivals) |
Not a negotiating partner. |
Diplomacy.js:151,163 (request targets) |
Nobody demands you join a war against barbarians. |
AI.computeStrategy:59 atWarWith |
See §1.3 — the other trap. |
AI.doDiplomacy:86 |
Loops living civs, and rel === 'war' would make every AI spam proposeOrQueue(ceasefire) at the barbarians forever. |
AI.sharedEnemy:163, AI:493 |
Barbarians must not count as a shared enemy or a "hostile neighbour nearby". |
Logic.beginCivTurn |
Skip progressRevolution / updateAttitudes / processCity / progressResearch, and skip the !civ.researching auto-assign block (barbarians would otherwise pick a research target every turn). Keep the unit MP reset and healing — barbarians want those. |
1.2 Trap: updateAttitudes shared-enemy bonus
updateAttitudes awards baseline += 20 for every third civ that both parties
are at war with. Since every civ is permanently at war with the barbarians,
adding them naively gives every AI pair a permanent +20 friendship bonus,
plus a -40 war-baseline penalty applied against the barbarians themselves.
Diplomacy would drift toward everyone liking everyone, quietly gutting the war
paths that took three rounds of tuning to get right (see the frustration/grudge
work in CivilizationDiplomacy.js).
Fix: barbarians are skipped in both the other loop and the inner third
loop. Verify pins this with a fixture asserting the attitude delta between two
neutral AI civs is unchanged with barbarians present vs absent.
1.3 Trap: atWarWith and AI war posture
computeStrategy sets phase = 'war' whenever atWarWith.length is non-empty.
With barbarians as a war relation, every AI sits in war phase for the entire
game: wantGarrison jumps to 2 permanently, manageCityBuild never reaches
the expand/space branches, and stepMilitary's peacetime scouting slice
(unit.id % 3 === 0 && strategy.atWarWith.length === 0) never runs, so AIs stop
exploring and stop popping huts.
Fix: barbarians are excluded from atWarWith, and computeStrategy gains a
separate signal:
barbarianThreat: 0 | 1 | 2 // 0 none, 1 raiders in the region, 2 adjacent to a city
Consumers:
manageCityBuild—wantGarrison = phase === 'war' ? 2 : (barbarianThreat >= 2 ? 2 : 1). The existing emergency-buy check already readsrelations[u.civ] === 'war', so barbarians at the gate trigger a rush-buy for free.stepMilitary— the adjacent-attack loop also already filters onciv.relations[u.civ] === 'war', so AIs attack adjacent barbarians for free. What is not free: the "march on the weakest reachable enemy city" block filters onstrategy.atWarWith, so a barbarian-held city would never be retaken. Add barbarian-held cities to that target list explicitly.- Peacetime scouting stays gated on
atWarWithonly, so exploration survives.
1.4 Trap: atPeace and the government ladder — found during the build
Not predicted in review; caught by the step-2 no-op checkpoint exactly as
intended. CivilizationAI.js's
function atPeace(state, civIdx) {
return !Object.values(state.civs[civIdx].relations).includes('war');
}
reads the raw relations map, so the permanent barbarian 'war' entry makes
every civ eternally at war. doGovernment gates Republic and Democracy on
atPeace and Communism on !atPeace, so with barbarians on, no AI would ever
leave Monarchy for a trade government — they'd all beeline Communism. Silent,
game-wide, and invisible in any single-turn fixture.
Fixed by iterating civs and skipping barbarians rather than reading the map
wholesale. The general lesson: any aggregate over civ.relations is suspect
— a per-pair lookup is fine, Object.values(relations) is not. A grep for
Object.values/entries/keys(...relations) found this as the only instance in
the game code (two more live in verify's own invariant checks, which iterate
pairs and are unaffected).
1.5 Trap: barbarians looting huts — found during the build
resolveHut fires for whoever steps on the hut, so raiders were popping them:
free units outside the population cap (the cap probe caught a stack of 5
against a cap of 4), plus gold and free ancient techs. Barbarians now never pop
huts — flavour-correct (they're who lives there) and the hut stays standing for
a real civ to find.
1.6 Trap: eliminateCiv killing the barbarian faction — found during the build
checkVictory skips barbarians, but captureCity also calls eliminateCiv
whenever the loser's last city changes hands. So retaking the one city
barbarians had seized killed the whole faction: alive = false, every raider
deleted, and — because runAITurn early-returns on dead civs — no further
barbarian activity for the rest of the game, silently. It surfaced only as an
"unit of dead civ" invariant break in 3 of 30 soak games, once a hut later
unleashed raiders belonging to the dead faction.
Guarded at the single choke point: eliminateCiv refuses to kill a barbarian
civ, so both callers are covered.
1.7 civPower
civPower sums unit attack+defense and city size. It's only called per index,
and once barbarians are out of doDiplomacy and atWarWith nothing computes a
power ratio against them — no change needed. Noted here so a future reader
doesn't "fix" it.
2. Spawning
2.1 Pressure curve (the fade-out)
Pressure is computed per target civ, from that civ's own era, so a runaway leader stops being raided while a laggard still gets harassed — the classic Civ behaviour, and it keeps barbarians from being pure noise late.
New shared helper in Logic (useful beyond this feature):
export function civEra(rules, civ) // most advanced era with >= 3 known techs of that era
Eras in civilization-rules.json are ancient → medieval → industrial → modern
(24 / 19 / 22 / 18 techs).
"barbarians": {
"enabled": true,
"firstTurn": 12,
"spawnIntervalBase": 9,
"eraPressure": { "ancient": 1.0, "medieval": 0.55, "industrial": 0.15, "modern": 0 },
"hardStopTechs": ["industrialization", "conscription", "railroad"],
…
}
A civ stops being targeted entirely once it knows any hardStopTech — i.e.
the moment it's building Riflemen/Cannon-era units, uprisings against it end.
eraPressure.modern = 0 is a belt-and-braces second stop.
Effective interval for civ c:
interval = spawnIntervalBase / (eraPressure[civEra(c)] * difficulty.barbarianActivity)
so Prince/ancient ≈ every 9 turns, Prince/medieval ≈ every 16, Prince/industrial
≈ every 60 (i.e. usually never before the hard stop lands). Each civ carries its
own nextSpawnTurn[civId] cursor on the barbarian civ.
Nothing spawns before firstTurn (12) — the opening land-grab stays clean.
2.2 Where and what
An uprising picks a wilderness tile 4–7 tiles from one of the target's cities: land, not water, no city on it, outside the target's current vision set (arrives as a surprise, but adjacent-explored so it isn't literally invisible), and not inside another civ's city radius.
Stack size 2 + randInt(3), scaled up by barbarianActivity.
Unit types come from the target's era, so barbarians never hand out free XP and never fight with sticks against Musketeers:
"unitsByEra": {
"ancient": ["warriors", "horsemen", "archers"],
"medieval": ["legion", "archers", "knights", "catapult"],
"industrial": ["musketeers", "dragoons", "cannon"],
"modern": ["riflemen", "cavalry", "artillery"]
}
Barbarian units spawn with vet: false and no veteran roll — they're a
sustained tax, not an alpha strike.
2.3 Hard cap (non-negotiable)
cap = min(capMax, capBase + floor(turn * capPerTurn)) * barbarianActivity
with capBase: 3, capPerTurn: 0.05, capMax: 12. Total living barbarian units
across the whole map. Without this they snowball into a fourth player and the
30-game soak goes sideways. Barbarians also never found cities and never
research — the cap plus zero growth is what keeps them an event rather than an
empire.
2.4 Huts
resolveHut's ambush branch (roll >= 0.85) currently fights a phantom
unit that never enters state.units. Upgrade it: when barbarians are enabled,
spawn a real 2–3 unit barbarian stack on the hut tile instead, then resolve the
attacker's fight against the top defender normally. ~10 lines, and it's the most
authentic Civ II barbarian moment in the whole feature. Falls back to the
existing phantom path when barbarians are disabled (old saves, Chieftain if we
ever zero it out).
3. Barbarian AI (CivilizationBarbarians.js, new 14th file)
Headless, no Phaser, mirroring CivilizationDiplomacy.js's shape. Exports:
createBarbarianCiv(rules, state)maybeSpawnUprisings(rules, state)— called frombeginCivTurnfor the barbarian civrunBarbarianTurn(rules, state)— therunAITurnequivalentbarbarianThreatFor(rules, state, civIdx)— the 0/1/2 signal forcomputeStrategytryRansom(rules, state, unit, leader)— shared by human moves and AI
Per-unit behaviour is deliberately dumber than stepMilitary:
- Adjacent enemy unit or city with acceptable odds → attack (reuse
tryMove, which already routes toresolveAttack/captureCity). - Otherwise march at the nearest non-barbarian city via
moveToward.moveTowardpathfinds once per decision and walks the whole path — the established perf pattern; re-planning per tile is what made AI turns slow. - Nothing reachable → wander one tile randomly.
They never fortify, never heal in place, never retreat. A stack that loses its war gets ground down, which is the point.
Captured cities. Size 1 is razed (destroyCity); size 2+ is captured via
the existing captureCity and held. A barbarian city does not grow, does not
build, and does not research — it just holds whatever garrison walked in.
Retaking it is normal conquest, and §1.3 makes sure the AI actually tries.
4. The Barbarian Leader
4.1 Drawing one out
The barbarian civ tallies barbKills[civId] — barbarian units that civ has
destroyed. Once the tally crosses leader.killsToSummon (6, scaled down by
barbarianActivity on higher difficulties), the next uprising against that
civ is promoted to a Horde: escortMin–escortMax (3–5) units escorting
one barbarianleader, and the tally resets.
This makes the mechanic self-reinforcing in the right direction — the player who successfully farms raiders is the one who gets the big prize dangled in front of them, and the player who's already drowning doesn't get a horde on top of it.
4.2 The unit
{ "id": "barbarianleader", "name": "Barbarian Leader", "domain": "land",
"attack": 0, "defense": 0, "move": 2, "hp": 10, "fp": 1, "cost": 0,
"prereq": null, "obsoletedBy": null, "flags": ["noncombat", "leader"],
"abbr": "BL", "frame": 0, "sheet": "barbarians" }
defense: 0 means it can never be the picked defender while an escort lives —
pickDefender sorts by defenderStrength, and the escort always outranks it.
Alone, it doesn't fight at all; it's captured (§4.4).
Its own turn behaviour (stepLeader) is flee: move to the adjacent tile
that maximises distance from the nearest non-barbarian combat unit, preferring
to stay within 2 of its escort if any survive. Cornered with nowhere better, it
sits still and waits to be captured.
leaderExpires = turn + leader.lifetime (20). On expiry it despawns with a
"the warlord slipped away into the hills" notice — that deadline is what turns
a leader sighting into a decision (chase it or hold the line) instead of a
free bag of gold you collect whenever.
4.3 Telling the player one is out there
Three layers, because a single popup gets dismissed and forgotten:
- Sighting popup. On horde spawn, the target's
exploredis set for the leader's tile andannounceStatusfires: "A Barbarian Warlord has been sighted near Stonehaven!" — using the existing 4thextra: { label, onClick }param (added forbuildingDone) with a SHOW ME button that pans the camera to the tile. That param exists precisely for "announce + jump somewhere", so no new plumbing. - Persistent HUD marker. While a leader lives, a small banner sits in the
HUD:
⚑ WARLORD AT LARGE — 14 turns, counting downleaderExpires. Clicking it pans to the leader's last known position — updated every turn the leader is genuinely visible to the human, and left stale (drawn dimmed) when it isn't. So it goes dark when the leader escapes your vision, and you hunt from the last sighting rather than getting a free tracker. - Map + minimap marker. A pulsing gold chevron above the leader's ring in
MapView.drawUnit, plus a gold dot on the minimap at the last known tile.
Ransom collected, leader escaped, and leader killed in the crossfire each get
their own announceStatus line so the arc always closes.
4.4 Ransom
Moving a unit onto a tile holding only a barbarian leader captures it
instead of fighting. tryMove grows a leader branch ahead of its combat branch,
returning result: 'ransom'.
ransom = (leader.ransomBase + leader.ransomPerEra * eraIndex(captor))
* difficulty.barbarianActivity
100g in the ancient era on Prince, scaling to ~250g by industrial and higher on
Emperor — so the same difficulty knob that makes barbarians nastier makes the
prize bigger. The captor's civ gets the gold, the leader is removed, an event
{ type: 'ransom', civ, gold, x, y } fires, and the human gets a popup with the
gold amount.
If the leader is stacked with escorts, combat proceeds normally and the leader dies with the last defender — no ransom. Killing the escort and then stepping on the leader is the intended play, and it's why the leader flees.
AI civs collect too: stepMilitary gains an adjacent-lone-leader check ahead of
its attack loop. Otherwise the human gets every ransom in the game, which is
both unfair and boring.
5. Presentation
- Colour.
rules.barbarianColor = '#3f1d1d'(dark crimson), deliberately outsideplayerColorsso no real civ can collide with it. The existing ring under each unit indrawUnitcarries it, so barbarians are visually distinct even with no new art at all. - Art (optional but wanted). A parallel
civilization-barbarians.pngsheet selected by asheetfield on the unit def, rather than spending the 5 free frames (51–55) on the sharedcivilization-unitssheet — 5 won't be enough once the leader and per-era raider variants are in.MapView.drawUnitpicksdef.sheet ? 'civilization-' + def.sheet : 'civilization-units'and falls back to the shared sheet when the texture is missing, so the feature ships and plays correctly before any art exists. Spec goes insprites.mdas section 7.- Suggested frames: raider (ancient), horseman, archer, medieval marauder, horde rider, musket raider, artillery crew, barbarian leader.
- Tooltips.
CivilizationTooltips.jsgains barbarian phrasing — no diplomacy line, no attitude, no "at war since"; the leader tooltip states the ransom value and turns remaining.
6. Difficulty wiring
One new field per entry in rules.difficulties:
| Difficulty | barbarianActivity |
|---|---|
| Chieftain | 0.35 |
| Warlord | 0.60 |
| Prince | 1.00 |
| King | 1.40 |
| Emperor | 1.80 |
It scales, in one place each: spawn interval (inversely), stack size, the unit
cap, killsToSummon (inversely — hordes come sooner on hard), and the ransom
payout. 0 disables barbarians entirely, which is what the barbarian civ's
creation gate reads, so a future "Barbarians: off" toggle is a one-line change.
7. Files touched
| File | Change |
|---|---|
data/civilization-rules.json |
barbarians block, barbarianColor, barbarianleader unit, barbarianActivity × 5 difficulties |
CivilizationRules.js |
compileRules validation: unit ids in unitsByEra exist, era keys match techs[].era, hardStopTechs exist, caps sane |
CivilizationLogic.js |
barbarian civ in createGame; isBarbarian + civEra helpers; guards per §1.1; tryMove ransom branch; resolveHut real-ambush |
CivilizationBarbarians.js |
new — spawn, turn, leader, ransom, threat signal |
CivilizationAI.js |
atWarWith exclusion + barbarianThreat; doDiplomacy skip; barbarian cities as retake targets; AI ransom capture |
CivilizationGame.js |
barbarian civ in stepCiv; sighting popup; HUD warlord banner; ransom/escape/raze notices |
CivilizationMapView.js |
barbarian sheet lookup + fallback; leader chevron; minimap marker |
CivilizationScreens.js |
filter barbarians from contacts, rivals, scores |
CivilizationDiplomacy.js |
filter barbarians from request targets |
CivilizationTooltips.js |
barbarian + leader tooltips |
tools/verifyCivilization.js |
new section 9 (below) |
sprites.md |
§7 barbarian sheet spec |
8. Verification
New section 9 in tools/verifyCivilization.js:
- Rules integrity — every id in
unitsByEra/hardStopTechsresolves; era keys cover every era present intechs;barbarianActivityon all 5 difficulties. - Save compat — a state built without
barbarianIndex(the pre-feature shape) runs a full turn through every touched function without throwing. Same trick section 6b uses, where the oldmkCivshape doubles as a compat test. - Isolation fixtures — the two traps get pinned directly:
- attitude delta between two neutral AI civs is identical with barbarians present and absent (§1.2);
computeStrategy(...).phasefor a peaceful AI with barbarians on the map is stillexpand/develop, neverwar(§1.3).
- Curve — spawn interval monotonically increases with era; zero uprisings
after any
hardStopTech; unit cap never exceeded across a 200-turn headless run at Emperor. - Leader arc — kills accumulate → horde spawns with an escort → leader flees
from an approaching unit → lone leader is ransomed for the era-correct gold →
tally reset; and separately, an unclaimed leader despawns exactly at
leaderExpires. - City capture — size-1 razed, size-2 held, barbarian city neither grows nor builds across 20 turns, and an AI at peace actually marches on it.
Then the standing 30-game soak at turnCap 1000 is re-run and compared against the baseline.
Results as built (baseline → with barbarians):
| baseline | with barbarians | |
|---|---|---|
| games decided | 15/30 | 20/30 |
| conquest / spaceship | 9 / 6 | 16 / 4 |
| avg AI turn | 4.76ms | 2.27ms* |
| total checks | 1258 | 1346 |
* lower only because the barbarians' own (cheap) turn is counted in the average; per-real-civ cost is unchanged.
One check fails, space flight window turn 150-600 — it fails identically on
main (median 836 there, 864 here), so it is pre-existing and unrelated.
Tuning note. spawnIntervalBase/capBase started at 9/3 and the world
tipped almost entirely to conquest (20 conquest vs 1 spaceship win), because
razed cities cripple civs into easy targets for each other. 14/2 keeps the
pressure — 900+ uprisings and 200 leader sightings across the suite — while
leaving the spaceship path alive. Raising them again will collapse the victory
mix; the numbers are recorded in the rules JSON readme too.
The escalation arc in section 6b strips the barbarian civ: it needs a stable ~90-turn runway for a grudge to build through refusals, and raids end the game in conquest first. Confirmed the escalation itself still works — the game is simply over — so this is a fixture runway issue, not a diplomacy regression.
Per the owner's standing preference, no browser verification — engine and Node checks only; the browser pass is theirs.
9. Build order
- Rules data +
compileRulesvalidation +sprites.mdspec. - Barbarian civ in
createGame, the §1.1 guards,civEra. Ship this alone and confirm the soak is unchanged — a barbarian civ that spawns nothing must be a perfect no-op. This is the checkpoint that catches §1.2/§1.3. CivilizationBarbarians.js: spawning, cap, curve, dumb march-and-attack.- AI response: threat signal, garrison priority, retake barbarian cities.
- Leader: summon tally, flee behaviour, expiry.
- Ransom:
tryMovebranch, AI capture, gold scaling. - Presentation: colour, sheet lookup + fallback, chevron, popups, HUD banner.
- Huts spawn real barbarians.
- Verify section 9, then the 30-game soak and seed re-pick.