diff --git a/index.html b/index.html
new file mode 100644
index 0000000..6434e0a
--- /dev/null
+++ b/index.html
@@ -0,0 +1,91 @@
+
+
+
+
+
+ Emergent Civilization Simulation
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/script.js b/script.js
new file mode 100644
index 0000000..fbdec66
--- /dev/null
+++ b/script.js
@@ -0,0 +1,1485 @@
+const Terrain = Object.freeze({
+ PLAINS: 0,
+ FOREST: 1,
+ MOUNTAIN: 2,
+ WATER: 3,
+ DESERT: 4,
+ FERTILE: 5,
+ MINERAL: 6
+});
+
+const terrainInfo = [
+ { name: "Plains", color: [82, 102, 74], move: 1.0, fertility: 0.55, mineral: 0.05, regen: 0.025 },
+ { name: "Forest", color: [58, 91, 68], move: 1.3, fertility: 0.62, mineral: 0.08, regen: 0.038 },
+ { name: "Mountains", color: [104, 105, 101], move: 2.6, fertility: 0.18, mineral: 0.78, regen: 0.012 },
+ { name: "Water", color: [52, 78, 103], move: 7.0, fertility: 0.04, mineral: 0.02, regen: 0.004 },
+ { name: "Desert", color: [139, 123, 86], move: 1.8, fertility: 0.1, mineral: 0.12, regen: 0.007 },
+ { name: "Fertile", color: [94, 122, 78], move: 0.9, fertility: 0.92, mineral: 0.04, regen: 0.055 },
+ { name: "Mineral", color: [118, 101, 94], move: 1.5, fertility: 0.38, mineral: 0.95, regen: 0.018 }
+];
+
+const els = {
+ canvas: document.getElementById("world"),
+ toggleRun: document.getElementById("toggleRun"),
+ stepOnce: document.getElementById("stepOnce"),
+ resetWorld: document.getElementById("resetWorld"),
+ saveWorld: document.getElementById("saveWorld"),
+ loadWorld: document.getElementById("loadWorld"),
+ clearSave: document.getElementById("clearSave"),
+ speed: document.getElementById("speed"),
+ agentCount: document.getElementById("agentCount"),
+ worldSize: document.getElementById("worldSize"),
+ viewMode: document.getElementById("viewMode"),
+ year: document.getElementById("year"),
+ population: document.getElementById("population"),
+ activeGroups: document.getElementById("activeGroups"),
+ urbanPopulation: document.getElementById("urbanPopulation"),
+ ethnicities: document.getElementById("ethnicities"),
+ cities: document.getElementById("cities"),
+ routes: document.getElementById("routes"),
+ deaths: document.getElementById("deaths"),
+ frameCost: document.getElementById("frameCost"),
+ ethnicityList: document.getElementById("ethnicityList"),
+ legend: document.getElementById("legend"),
+ tooltip: document.getElementById("tooltip")
+};
+
+const ctx = els.canvas.getContext("2d", { alpha: false });
+let sim;
+let running = true;
+let frame = 0;
+let lastStatsAt = 0;
+let hoverState = null;
+
+class Rng {
+ constructor(seed) {
+ this.seed = seed >>> 0;
+ }
+
+ next() {
+ this.seed = (1664525 * this.seed + 1013904223) >>> 0;
+ return this.seed / 4294967296;
+ }
+
+ range(min, max) {
+ return min + (max - min) * this.next();
+ }
+
+ int(max) {
+ return Math.floor(this.next() * max);
+ }
+}
+
+class World {
+ constructor(size, rng) {
+ this.size = size;
+ this.count = size * size;
+ this.rng = rng;
+ this.terrain = new Uint8Array(this.count);
+ this.resource = new Float32Array(this.count);
+ this.regen = new Float32Array(this.count);
+ this.move = new Float32Array(this.count);
+ this.fertility = new Float32Array(this.count);
+ this.mineral = new Float32Array(this.count);
+ this.pheromone = new Float32Array(this.count);
+ this.tradeRoute = new Uint8Array(this.count);
+ this.farmland = new Float32Array(this.count);
+ this.cityPull = new Float32Array(this.count);
+ this.city = new Int32Array(this.count);
+ this.pressure = new Float32Array(this.count);
+ this.city.fill(-1);
+ this.generate();
+ }
+
+ idx(x, y) {
+ return y * this.size + x;
+ }
+
+ generate() {
+ const s = this.size;
+ const centers = Array.from({ length: 18 }, () => ({
+ x: this.rng.range(0, s),
+ y: this.rng.range(0, s),
+ kind: this.rng.next()
+ }));
+
+ for (let y = 0; y < s; y++) {
+ for (let x = 0; x < s; x++) {
+ const i = this.idx(x, y);
+ const nx = x / s - 0.5;
+ const ny = y / s - 0.5;
+ const latitude = Math.abs(ny) * 0.18;
+ let elevation = 0.55 - Math.hypot(nx, ny) * 0.72 + this.smoothNoise(x, y, 42) * 0.38 + this.smoothNoise(x + 900, y - 300, 18) * 0.16;
+ let moisture = this.smoothNoise(x + 500, y - 330, 54) * 0.68 + this.smoothNoise(x, y, 24) * 0.24 - latitude;
+ let minerals = this.smoothNoise(x - 290, y + 120, 20);
+
+ for (const c of centers) {
+ const d = Math.hypot(x - c.x, y - c.y) / s;
+ if (c.kind < 0.3) elevation += Math.max(0, 0.24 - d) * 0.85;
+ if (c.kind > 0.72) moisture += Math.max(0, 0.22 - d) * 1.0;
+ }
+
+ let t = Terrain.PLAINS;
+ if (elevation < 0.24) t = Terrain.WATER;
+ else if (elevation > 0.68) t = minerals > 0.56 ? Terrain.MINERAL : Terrain.MOUNTAIN;
+ else if (moisture < 0.22) t = Terrain.DESERT;
+ else if (moisture > 0.72 && elevation < 0.54) t = Terrain.FERTILE;
+ else if (moisture > 0.5) t = Terrain.FOREST;
+
+ const info = terrainInfo[t];
+ this.terrain[i] = t;
+ this.fertility[i] = clamp(info.fertility + this.rng.range(-0.07, 0.07), 0, 1);
+ this.mineral[i] = clamp(info.mineral + minerals * 0.16, 0, 1);
+ this.regen[i] = info.regen * (0.7 + this.fertility[i]);
+ this.move[i] = info.move;
+ this.resource[i] = this.terrain[i] === Terrain.WATER ? 0 : this.rng.range(4, 18) * (0.5 + this.fertility[i] + this.mineral[i] * 0.35);
+ }
+ }
+ this.enrichWaterMargins();
+ }
+
+ enrichWaterMargins() {
+ const s = this.size;
+ const nextFertility = new Float32Array(this.fertility);
+ for (let y = 0; y < s; y++) {
+ for (let x = 0; x < s; x++) {
+ const i = this.idx(x, y);
+ if (this.terrain[i] === Terrain.WATER) continue;
+ let waterScore = 0;
+ for (let dy = -3; dy <= 3; dy++) {
+ for (let dx = -3; dx <= 3; dx++) {
+ const d = Math.abs(dx) + Math.abs(dy);
+ if (!d || d > 3) continue;
+ const tx = x + dx;
+ const ty = y + dy;
+ if (tx < 0 || ty < 0 || tx >= s || ty >= s) continue;
+ if (this.terrain[this.idx(tx, ty)] === Terrain.WATER) waterScore += (4 - d) / 4;
+ }
+ }
+ if (waterScore <= 0) continue;
+ const boost = Math.min(0.32, waterScore * 0.08);
+ nextFertility[i] = clamp(this.fertility[i] + boost, 0, 1);
+ if (this.terrain[i] === Terrain.DESERT && waterScore > 1.2) this.terrain[i] = Terrain.PLAINS;
+ if ((this.terrain[i] === Terrain.PLAINS || this.terrain[i] === Terrain.FOREST) && nextFertility[i] > 0.82) this.terrain[i] = Terrain.FERTILE;
+ }
+ }
+
+ for (let i = 0; i < this.count; i++) {
+ const info = terrainInfo[this.terrain[i]];
+ this.fertility[i] = nextFertility[i];
+ this.regen[i] = info.regen * (0.7 + this.fertility[i]);
+ this.move[i] = info.move;
+ if (this.terrain[i] !== Terrain.WATER) {
+ this.resource[i] = Math.max(this.resource[i], this.rng.range(5, 20) * (0.45 + this.fertility[i] + this.mineral[i] * 0.25));
+ }
+ }
+ }
+
+ noise(x, y, scale) {
+ const sx = Math.sin((x * 12.9898 + y * 78.233) / scale) * 43758.5453;
+ const v = sx - Math.floor(sx);
+ const sx2 = Math.sin(((x + scale) * 93.989 + (y - scale) * 67.345) / (scale * 1.7)) * 24634.6345;
+ return (v + sx2 - Math.floor(sx2)) * 0.5;
+ }
+
+ smoothNoise(x, y, scale) {
+ const x0 = Math.floor(x / scale);
+ const y0 = Math.floor(y / scale);
+ const fx = smoothstep((x / scale) - x0);
+ const fy = smoothstep((y / scale) - y0);
+ const a = this.gridNoise(x0, y0);
+ const b = this.gridNoise(x0 + 1, y0);
+ const c = this.gridNoise(x0, y0 + 1);
+ const d = this.gridNoise(x0 + 1, y0 + 1);
+ return lerp(lerp(a, b, fx), lerp(c, d, fx), fy);
+ }
+
+ gridNoise(x, y) {
+ const v = Math.sin(x * 127.1 + y * 311.7 + this.rng.seed * 0.000001) * 43758.5453123;
+ return v - Math.floor(v);
+ }
+}
+
+class Simulation {
+ constructor(size, initialAgents) {
+ this.rng = new Rng(Date.now());
+ this.world = new World(size, this.rng);
+ this.agents = [];
+ this.ethnicities = new Map();
+ this.cities = [];
+ this.tradeLinks = [];
+ this.nextEthnicity = 1;
+ this.nextCity = 1;
+ this.year = 0;
+ this.deaths = 0;
+ this.maxAgents = Math.max(initialAgents * 1.25, 30000);
+ this.tileEthnicities = new Map();
+ this.spawnInitialAgents(initialAgents);
+ this.rebuildOccupancy();
+ this.updateEthnicStats();
+ }
+
+ spawnInitialAgents(count) {
+ const founders = Math.max(5, Math.min(16, Math.round(count / 180)));
+ for (let e = 0; e < founders; e++) {
+ const id = this.createEthnicity(0);
+ const origin = this.findHabitableTile();
+ const baseTraits = this.randomTraits();
+ for (let n = 0; n < Math.floor(count / founders); n++) {
+ this.agents.push(this.makeAgent(
+ clamp(origin.x + this.rng.int(9) - 4, 0, this.world.size - 1),
+ clamp(origin.y + this.rng.int(9) - 4, 0, this.world.size - 1),
+ id,
+ mutateTraits(baseTraits, this.rng, 0.07),
+ this.rng.range(6, 15)
+ ));
+ }
+ }
+ }
+
+ createEthnicity(parent) {
+ const id = this.nextEthnicity++;
+ this.ethnicities.set(id, {
+ id,
+ parent,
+ born: this.year,
+ population: 0,
+ diversity: 0,
+ color: hslToRgb((id * 0.61803398875) % 1, 0.55, 0.56),
+ centroidX: 0,
+ centroidY: 0
+ });
+ return id;
+ }
+
+ randomTraits() {
+ return {
+ mobility: this.rng.range(0.18, 0.85),
+ resourceAttraction: this.rng.range(0.55, 1),
+ assimilation: this.rng.range(0.03, 0.34),
+ ethnocentrism: this.rng.range(0.12, 0.78),
+ reproductionThreshold: this.rng.range(19, 34),
+ oceanic: this.rng.next() < 0.18 ? this.rng.range(0.48, 0.9) : this.rng.range(0.02, 0.28),
+ sedentary: this.rng.range(0.08, 0.82)
+ };
+ }
+
+ makeAgent(x, y, ethnicity, traits, resources) {
+ return {
+ x,
+ y,
+ resources,
+ ethnicity,
+ traits,
+ alive: true,
+ foreignContact: 0,
+ contactEthnicity: ethnicity,
+ settled: 0
+ };
+ }
+
+ findHabitableTile() {
+ for (let tries = 0; tries < 5000; tries++) {
+ const x = this.rng.int(this.world.size);
+ const y = this.rng.int(this.world.size);
+ const i = this.world.idx(x, y);
+ if (this.world.terrain[i] !== Terrain.WATER && this.world.fertility[i] + this.world.mineral[i] > 0.5) {
+ return { x, y };
+ }
+ }
+ return { x: this.world.size >> 1, y: this.world.size >> 1 };
+ }
+
+ step() {
+ for (const city of this.cities) city.activeVisitors = 0;
+ this.rebuildOccupancy();
+
+ const offspring = [];
+ for (const a of this.agents) {
+ if (!a.alive) continue;
+ this.moveAgent(a);
+ this.gatherConsumeReproduce(a, offspring);
+ this.resolveAssimilation(a);
+ }
+
+ this.agents = this.agents.filter(a => a.alive);
+ if (this.agents.length + offspring.length < this.maxAgents) {
+ this.agents.push(...offspring);
+ } else {
+ this.agents.push(...offspring.slice(0, Math.max(0, this.maxAgents - this.agents.length)));
+ }
+
+ this.updateWorldFields();
+ this.rebuildOccupancy();
+ this.updateCities();
+ this.updateTradeRoutes();
+ this.updateEthnicStats();
+ if (this.year % 90 === 0) this.splitDivergentEthnicities();
+ this.year++;
+ }
+
+ rebuildOccupancy() {
+ const w = this.world;
+ w.pressure.fill(0);
+ this.tileEthnicities.clear();
+ for (const a of this.agents) {
+ if (!a.alive) continue;
+ const i = w.idx(a.x, a.y);
+ w.pressure[i]++;
+ let counts = this.tileEthnicities.get(i);
+ if (!counts) {
+ counts = new Map();
+ this.tileEthnicities.set(i, counts);
+ }
+ counts.set(a.ethnicity, (counts.get(a.ethnicity) || 0) + 1);
+ }
+ }
+
+ moveAgent(a) {
+ const w = this.world;
+ const s = w.size;
+ const current = w.idx(a.x, a.y);
+ const localPressure = w.pressure[current];
+ let bestX = a.x;
+ let bestY = a.y;
+ let bestScore = -Infinity;
+ const sedentary = getSedentary(a.traits);
+ const ethnocentrism = getEthnocentrism(a.traits);
+ if (localPressure < 7 && this.rng.next() < sedentary * 0.58) {
+ a.settled++;
+ return;
+ }
+ const radius = a.traits.mobility > 0.62 && sedentary < 0.52 && this.rng.next() < 0.16 ? 2 : 1;
+
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ const x = clamp(a.x + dx, 0, s - 1);
+ const y = clamp(a.y + dy, 0, s - 1);
+ const i = w.idx(x, y);
+ const isWater = w.terrain[i] === Terrain.WATER;
+ const oceanic = a.traits.oceanic ?? 0.12;
+ const waterAccess = oceanic * 0.42 + a.traits.mobility * 0.08 + (w.tradeRoute[i] ? 0.18 : 0);
+ if (isWater && this.rng.next() > waterAccess) continue;
+
+ const sameDensity = this.sameEthnicityNear(x, y, a.ethnicity);
+ const foreignDensity = this.foreignEthnicityNear(x, y, a.ethnicity);
+ const cityPull = w.city[i] >= 0 ? 1.45 : w.cityPull[i];
+ const routePull = (w.tradeRoute[i] ? 1.35 : clamp(w.pheromone[i] / 8, 0, 1)) * sedentary;
+ const resourceScore = w.resource[i] * 0.12 + w.fertility[i] * 1.2 + w.mineral[i] * 0.42 + (isWater ? oceanic * 0.65 : 0);
+ const crowdPenalty = Math.max(0, w.pressure[i] - 3) * (0.35 + a.traits.mobility);
+ const routeBonus = w.tradeRoute[i] ? 1.25 : 0;
+ const waterPenalty = isWater ? 2.2 - oceanic * 1.35 : 0;
+ const terrainPenalty = w.move[i] * (0.8 - a.traits.mobility * 0.35) + waterPenalty - routeBonus;
+ const inertia = dx === 0 && dy === 0 ? sedentary * 3.2 : 0;
+ const pressurePush = localPressure > 8 ? a.traits.mobility * (1.65 - sedentary * 0.7) : 0;
+
+ const score =
+ resourceScore * a.traits.resourceAttraction +
+ w.pheromone[i] * 0.021 +
+ sameDensity * ethnocentrism +
+ cityPull * sedentary +
+ routePull +
+ inertia -
+ terrainPenalty -
+ crowdPenalty +
+ pressurePush -
+ foreignDensity * ethnocentrism * 0.72 +
+ this.rng.range(-0.55, 0.55);
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestX = x;
+ bestY = y;
+ }
+ }
+ }
+
+ const from = w.idx(a.x, a.y);
+ a.x = bestX;
+ a.y = bestY;
+ const to = w.idx(a.x, a.y);
+ if (from !== to) {
+ const depositScale = w.terrain[to] === Terrain.WATER ? 0.35 : 1;
+ w.pheromone[from] += (w.tradeRoute[from] ? 0.08 : 0.22) * depositScale;
+ w.pheromone[to] += (w.tradeRoute[to] ? 0.06 : 0.15) * depositScale;
+ a.settled = Math.max(0, a.settled - 1);
+ } else {
+ a.settled++;
+ }
+ }
+
+ gatherConsumeReproduce(a, offspring) {
+ const w = this.world;
+ const i = w.idx(a.x, a.y);
+ const cityMarket = w.city[i] >= 0 ? 0.28 : 0;
+ const productivity = 0.45 + w.fertility[i] * 1.15 + w.mineral[i] * 0.45 + w.farmland[i] * 0.32 + cityMarket;
+ const pressurePenalty = 1 / (1 + Math.max(0, w.pressure[i] - 2) * 0.18);
+ const gathered = Math.min(w.resource[i], productivity * pressurePenalty * this.rng.range(0.45, 1.2));
+ w.resource[i] -= gathered;
+ a.resources += gathered;
+ const waterCost = w.terrain[i] === Terrain.WATER ? 0.32 - (a.traits.oceanic ?? 0.12) * 0.18 : 0;
+ a.resources -= 0.72 + w.move[i] * 0.06 + waterCost;
+
+ if (a.resources <= 0) {
+ a.alive = false;
+ this.deaths++;
+ return;
+ }
+
+ if (a.resources > a.traits.reproductionThreshold && offspring.length < 700) {
+ a.resources *= 0.58;
+ const childTraits = mutateTraits(a.traits, this.rng, 0.035);
+ offspring.push(this.makeAgent(a.x, a.y, a.ethnicity, childTraits, a.resources * 0.42));
+ }
+ }
+
+ sameEthnicityNear(x, y, ethnicity) {
+ let count = 0;
+ const w = this.world;
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) > 2) continue;
+ const tx = x + dx;
+ const ty = y + dy;
+ if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+ const counts = this.tileEthnicities.get(w.idx(tx, ty));
+ if (!counts) continue;
+ count += counts.get(ethnicity) || 0;
+ if (count > 6) return count * 0.22;
+ }
+ }
+ return count * 0.22;
+ }
+
+ foreignEthnicityNear(x, y, ethnicity) {
+ let count = 0;
+ const w = this.world;
+ for (let dy = -2; dy <= 2; dy++) {
+ for (let dx = -2; dx <= 2; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) > 2) continue;
+ const tx = x + dx;
+ const ty = y + dy;
+ if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+ const counts = this.tileEthnicities.get(w.idx(tx, ty));
+ if (!counts) continue;
+ for (const [id, value] of counts) {
+ if (id !== ethnicity) count += value;
+ }
+ if (count > 6) return count * 0.16;
+ }
+ }
+ return count * 0.16;
+ }
+
+ resolveAssimilation(a) {
+ const dominant = this.dominantEthnicityNear(a.x, a.y, a.ethnicity);
+ if (!dominant || dominant.id === a.ethnicity) {
+ a.foreignContact = Math.max(0, a.foreignContact - 1);
+ return;
+ }
+
+ if (a.contactEthnicity !== dominant.id) {
+ a.contactEthnicity = dominant.id;
+ a.foreignContact = 0;
+ }
+ a.foreignContact++;
+
+ const pressure = dominant.count / Math.max(1, dominant.total);
+ const chance = a.traits.assimilation * pressure * Math.min(1, a.foreignContact / 120);
+ if (this.rng.next() < chance * 0.035) {
+ a.ethnicity = dominant.id;
+ a.traits = blendTraits(a.traits, dominant.averageTraits, 0.08 + a.traits.assimilation * 0.22);
+ a.foreignContact = 0;
+ }
+ }
+
+ dominantEthnicityNear(x, y, self) {
+ const counts = new Map();
+ let total = 0;
+ const w = this.world;
+ for (let dy = -3; dy <= 3; dy++) {
+ for (let dx = -3; dx <= 3; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) > 3) continue;
+ const tx = x + dx;
+ const ty = y + dy;
+ if (tx < 0 || ty < 0 || tx >= w.size || ty >= w.size) continue;
+ const tileCounts = this.tileEthnicities.get(w.idx(tx, ty));
+ if (!tileCounts) continue;
+ for (const [id, count] of tileCounts) {
+ total += count;
+ counts.set(id, (counts.get(id) || 0) + count);
+ }
+ }
+ }
+ let best = null;
+ for (const [id, count] of counts) {
+ if (id !== self && (!best || count > best.count)) best = { id, count, total };
+ }
+ if (best) best.averageTraits = this.ethnicities.get(best.id)?.averageTraits || this.randomTraits();
+ return best;
+ }
+
+ updateWorldFields() {
+ const w = this.world;
+ for (let i = 0; i < w.count; i++) {
+ w.resource[i] = Math.min(44, w.resource[i] + w.regen[i] * (1 + w.farmland[i] * 0.55));
+ w.pheromone[i] *= 0.992;
+ w.pressure[i] *= 0.88;
+ w.farmland[i] *= 0.985;
+ w.cityPull[i] *= 0.96;
+ }
+ }
+
+ updateCities() {
+ const w = this.world;
+ if (this.year % 8 === 0) w.city.fill(-1);
+
+ const candidates = new Map();
+ for (const a of this.agents) {
+ if (!a.alive || a.settled < 7) continue;
+ const local = w.idx(a.x, a.y);
+ if (w.terrain[local] === Terrain.WATER || w.fertility[local] < 0.34) continue;
+ const cx = clamp(Math.round(a.x / 2) * 2, 0, w.size - 1);
+ const cy = clamp(Math.round(a.y / 2) * 2, 0, w.size - 1);
+ const i = w.idx(cx, cy);
+ let group = candidates.get(i);
+ if (!group) {
+ group = { count: 0, resources: 0, ethnicities: new Map() };
+ candidates.set(i, group);
+ }
+ group.count++;
+ group.resources += Math.max(0, a.resources);
+ group.ethnicities.set(a.ethnicity, (group.ethnicities.get(a.ethnicity) || 0) + 1);
+ }
+
+ for (const [i, group] of candidates) {
+ if (group.count < 5) continue;
+ let city = this.cities.find(c => Math.abs(c.x - (i % w.size)) + Math.abs(c.y - Math.floor(i / w.size)) < 5);
+ if (!city && this.cities.length < 160) {
+ city = this.createCity(i % w.size, Math.floor(i / w.size), group);
+ this.cities.push(city);
+ }
+ if (city) {
+ city.activeVisitors += group.count;
+ city.storedResources += group.resources * 0.08;
+ for (const [id, count] of group.ethnicities) {
+ city.ethnicityComposition.set(id, (city.ethnicityComposition.get(id) || 0) + Math.ceil(count * 0.2));
+ }
+ city.strength = city.strength * 0.96 + group.count * 0.05;
+ }
+ }
+
+ this.absorbUrbanPopulation();
+ this.processCityEconomies();
+
+ this.cities = this.cities.filter(c => {
+ c.age++;
+ c.strength *= 0.996;
+ if (c.population < 10) c.strength -= 0.02;
+ if (c.population <= 0 || c.strength <= 0.35) return false;
+ const radius = Math.min(8, c.agriculturalRadius);
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ const x = clamp(c.x + dx, 0, w.size - 1);
+ const y = clamp(c.y + dy, 0, w.size - 1);
+ const d = Math.abs(dx) + Math.abs(dy);
+ if (d <= radius) {
+ const tile = w.idx(x, y);
+ w.city[tile] = c.id;
+ if (w.terrain[tile] !== Terrain.WATER) {
+ w.farmland[tile] = Math.max(w.farmland[tile], (radius - d + 1) / (radius + 1));
+ w.cityPull[tile] = Math.max(w.cityPull[tile], (radius - d + 1) / (radius + 1));
+ }
+ }
+ }
+ }
+ return true;
+ });
+ }
+
+ createCity(x, y, seedGroup = null) {
+ const seedPopulation = seedGroup ? Math.max(8, seedGroup.count * 3) : 8;
+ const composition = new Map();
+ if (seedGroup) {
+ for (const [id, count] of seedGroup.ethnicities) composition.set(id, Math.max(1, count * 3));
+ }
+ return {
+ id: this.nextCity++,
+ x,
+ y,
+ population: seedPopulation,
+ storedResources: 24 + (seedGroup?.resources || 0) * 0.4,
+ ethnicityComposition: composition,
+ pheromoneOutput: 0,
+ agriculturalRadius: 2,
+ tradeLinks: new Set(),
+ activeVisitors: 0,
+ age: 0,
+ strength: 2
+ };
+ }
+
+ absorbUrbanPopulation() {
+ if (!this.cities.length) return;
+ const absorbed = [];
+ for (const a of this.agents) {
+ if (!a.alive || a.settled < 9) {
+ absorbed.push(a);
+ continue;
+ }
+ const city = this.findCityNear(a.x, a.y, 5);
+ if (!city || this.rng.next() > getSedentary(a.traits) * 0.55) {
+ absorbed.push(a);
+ continue;
+ }
+ const migrants = 1 + Math.floor(Math.min(5, a.resources / 10));
+ city.population += migrants;
+ city.storedResources += Math.max(0, a.resources) * 0.65;
+ city.ethnicityComposition.set(a.ethnicity, (city.ethnicityComposition.get(a.ethnicity) || 0) + migrants);
+ city.strength += 0.04;
+ }
+ this.agents = absorbed;
+ }
+
+ processCityEconomies() {
+ const w = this.world;
+ for (const city of this.cities) {
+ city.agriculturalRadius = clamp(Math.floor(1 + Math.sqrt(city.population) / 4.5), 2, 14);
+ city.pheromoneOutput = clamp(Math.log2(city.population + 1) * 0.09, 0.15, 1.8);
+ let harvested = 0;
+ const radius = city.agriculturalRadius;
+
+ for (let dy = -radius; dy <= radius; dy++) {
+ for (let dx = -radius; dx <= radius; dx++) {
+ if (Math.abs(dx) + Math.abs(dy) > radius) continue;
+ const x = clamp(city.x + dx, 0, w.size - 1);
+ const y = clamp(city.y + dy, 0, w.size - 1);
+ const i = w.idx(x, y);
+ if (w.terrain[i] === Terrain.WATER) continue;
+ const pull = (radius - Math.abs(dx) - Math.abs(dy) + 1) / (radius + 1);
+ const extraction = Math.min(w.resource[i], (0.03 + w.fertility[i] * 0.09 + w.mineral[i] * 0.035) * pull);
+ w.resource[i] -= extraction;
+ w.farmland[i] = Math.max(w.farmland[i], pull);
+ w.pheromone[i] += city.pheromoneOutput * pull * 0.04;
+ harvested += extraction;
+ }
+ }
+
+ city.storedResources += harvested;
+ const upkeep = city.population * 0.012;
+ city.storedResources -= upkeep;
+ if (city.storedResources > city.population * 0.24 && city.population > 0) {
+ const births = Math.max(1, Math.floor(city.population * 0.004));
+ city.population += births;
+ city.storedResources -= births * 0.7;
+ addBirthsToComposition(city.ethnicityComposition, births);
+ }
+ if (city.storedResources < 0) {
+ const loss = Math.min(city.population, Math.ceil(Math.abs(city.storedResources) * 1.8));
+ city.population -= loss;
+ city.storedResources = 0;
+ this.deaths += loss;
+ removeFromComposition(city.ethnicityComposition, loss);
+ if (loss > 5 && this.agents.length < this.maxAgents) this.spawnUrbanRefugees(city, Math.min(18, Math.floor(loss / 3)));
+ }
+ city.population = Math.max(0, Math.floor(city.population));
+ city.storedResources = clamp(city.storedResources, 0, Math.max(30, city.population * 1.4));
+ }
+ }
+
+ spawnUrbanRefugees(city, count) {
+ const dominant = dominantComposition(city.ethnicityComposition) || 1;
+ const template = this.ethnicities.get(dominant)?.averageTraits || this.randomTraits();
+ for (let i = 0; i < count; i++) {
+ this.agents.push(this.makeAgent(
+ clamp(city.x + this.rng.int(7) - 3, 0, this.world.size - 1),
+ clamp(city.y + this.rng.int(7) - 3, 0, this.world.size - 1),
+ dominant,
+ mutateTraits(template, this.rng, 0.05),
+ this.rng.range(4, 11)
+ ));
+ }
+ }
+
+ findCityNear(x, y, radius) {
+ let best = null;
+ let bestDistance = Infinity;
+ for (const c of this.cities) {
+ const d = Math.abs(c.x - x) + Math.abs(c.y - y);
+ if (d <= radius && d < bestDistance) {
+ best = c;
+ bestDistance = d;
+ }
+ }
+ return best;
+ }
+
+ updateTradeRoutes() {
+ const w = this.world;
+ for (let i = 0; i < w.count; i++) {
+ const invalidTerrain = w.terrain[i] === Terrain.WATER || w.move[i] > 2.2;
+ if (invalidTerrain) w.tradeRoute[i] = 0;
+ else if (w.tradeRoute[i] > 0 && this.year % 4 === 0) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 2);
+ }
+
+ this.tradeLinks = [];
+ for (const city of this.cities) city.tradeLinks.clear();
+ const candidates = [];
+ for (let a = 0; a < this.cities.length; a++) {
+ for (let b = a + 1; b < this.cities.length; b++) {
+ const c1 = this.cities[a];
+ const c2 = this.cities[b];
+ const d = Math.abs(c1.x - c2.x) + Math.abs(c1.y - c2.y);
+ if (d > 34) continue;
+ const path = this.findTerrainRoute(c1.x, c1.y, c2.x, c2.y);
+ if (!this.isValidRoutePath(path, c2.x, c2.y)) continue;
+ const strength = this.routeStrengthForPath(path);
+ if (strength < 0.38) continue;
+ candidates.push({ c1, c2, strength, path });
+ }
+ }
+
+ candidates.sort((a, b) => b.strength - a.strength);
+ const maxLinks = Math.max(1, Math.floor(this.cities.length / 10));
+ const supportedRoutes = new Set();
+ for (const candidate of candidates) {
+ if (this.tradeLinks.length >= maxLinks) break;
+ const { c1, c2, strength, path } = candidate;
+ if (c1.tradeLinks.size >= 1 || c2.tradeLinks.size >= 1) continue;
+ c1.tradeLinks.add(c2.id);
+ c2.tradeLinks.add(c1.id);
+ this.tradeLinks.push({ from: c1.id, to: c2.id, strength, path });
+ this.exchangeCityResources(c1, c2, strength, path);
+ for (const tile of path) {
+ supportedRoutes.add(tile);
+ w.tradeRoute[tile] = Math.min(255, w.tradeRoute[tile] + 12);
+ }
+ }
+
+ for (let i = 0; i < w.count; i++) {
+ if (w.tradeRoute[i] && !supportedRoutes.has(i)) w.tradeRoute[i] = Math.max(0, w.tradeRoute[i] - 10);
+ }
+ }
+
+ findTerrainRoute(x1, y1, x2, y2) {
+ const w = this.world;
+ const path = [];
+ const visited = new Set();
+ let x = x1;
+ let y = y1;
+ const maxSteps = Math.min(w.size * 2, Math.abs(x1 - x2) + Math.abs(y1 - y2) + 60);
+ const directions = [
+ [1, 0],
+ [-1, 0],
+ [0, 1],
+ [0, -1]
+ ];
+
+ for (let step = 0; step < maxSteps; step++) {
+ const current = w.idx(x, y);
+ path.push(current);
+ visited.add(current);
+ if (x === x2 && y === y2) break;
+
+ let bestX = x;
+ let bestY = y;
+ let bestScore = Infinity;
+ for (const [dx, dy] of directions) {
+ const nx = x + dx;
+ const ny = y + dy;
+ if (nx < 0 || ny < 0 || nx >= w.size || ny >= w.size) continue;
+ const i = w.idx(nx, ny);
+ const terrainCost = w.move[i] + (w.terrain[i] === Terrain.WATER ? 8 : 0) + (w.terrain[i] === Terrain.MOUNTAIN ? 2.2 : 0);
+ const revisitCost = visited.has(i) ? 5 : 0;
+ const routeEase = w.tradeRoute[i] ? -2 : 0;
+ const pheromoneEase = -clamp(w.pheromone[i] / 12, 0, 1.4);
+ const distance = Math.abs(nx - x2) + Math.abs(ny - y2);
+ const score = distance * 1.4 + terrainCost * 1.65 + revisitCost + routeEase + pheromoneEase;
+ if (score < bestScore) {
+ bestScore = score;
+ bestX = nx;
+ bestY = ny;
+ }
+ }
+
+ if (bestX === x && bestY === y) break;
+ x = bestX;
+ y = bestY;
+ }
+ return path;
+ }
+
+ routeStrengthForPath(path) {
+ const w = this.world;
+ if (!path.length) return 0;
+ let route = 0;
+ let pheromone = 0;
+ let terrainEase = 0;
+ for (const i of path) {
+ route += w.tradeRoute[i] > 0 ? 1 : 0;
+ pheromone += clamp(w.pheromone[i] / 9, 0, 1);
+ terrainEase += 1 / Math.max(1, w.move[i]);
+ }
+ return route / path.length * 0.45 + pheromone / path.length * 0.35 + terrainEase / path.length * 0.2;
+ }
+
+ isValidRoutePath(path, targetX, targetY) {
+ const w = this.world;
+ if (!path.length) return false;
+ const last = path[path.length - 1];
+ if (last % w.size !== targetX || Math.floor(last / w.size) !== targetY) return false;
+ if (path.length > 42) return false;
+
+ let totalCost = 0;
+ let hardTiles = 0;
+ const seen = new Set();
+ for (const tile of path) {
+ if (seen.has(tile)) return false;
+ seen.add(tile);
+ if (w.terrain[tile] === Terrain.WATER) return false;
+ totalCost += w.move[tile];
+ if (w.move[tile] > 1.8) hardTiles++;
+ }
+ return totalCost / path.length <= 1.55 && hardTiles / path.length <= 0.12;
+ }
+
+ exchangeCityResources(a, b, strength, path) {
+ const delta = (a.storedResources - b.storedResources) * 0.018 * strength;
+ a.storedResources -= delta;
+ b.storedResources += delta;
+ const traffic = Math.min(0.8, strength * 0.26);
+ this.depositRoutePheromone(path, traffic);
+ }
+
+ depositRoutePheromone(path, amount) {
+ const w = this.world;
+ for (const i of path) {
+ w.pheromone[i] += amount;
+ }
+ }
+
+ updateEthnicStats() {
+ for (const e of this.ethnicities.values()) {
+ e.population = 0;
+ e.diversity = 0;
+ e.centroidX = 0;
+ e.centroidY = 0;
+ e.activeTraitPopulation = 0;
+ e.traitSums = emptyTraitSums();
+ }
+
+ for (const a of this.agents) {
+ const e = this.ethnicities.get(a.ethnicity);
+ if (!e) continue;
+ e.population++;
+ e.centroidX += a.x;
+ e.centroidY += a.y;
+ e.activeTraitPopulation++;
+ addTraits(e.traitSums, a.traits);
+ }
+
+ for (const city of this.cities) {
+ for (const [id, count] of city.ethnicityComposition) {
+ const e = this.ethnicities.get(id);
+ if (!e) continue;
+ e.population += count;
+ e.centroidX += city.x * count;
+ e.centroidY += city.y * count;
+ }
+ }
+
+ for (const e of this.ethnicities.values()) {
+ if (!e.population) continue;
+ e.centroidX /= e.population;
+ e.centroidY /= e.population;
+ const activeTraitCount = Math.max(1, e.activeTraitPopulation);
+ e.averageTraits = averageTraits(e.traitSums, activeTraitCount);
+ }
+
+ for (const a of this.agents) {
+ const e = this.ethnicities.get(a.ethnicity);
+ if (e?.averageTraits) e.diversity += traitDistance(a.traits, e.averageTraits);
+ }
+
+ for (const e of this.ethnicities.values()) {
+ if (e.population) e.diversity /= e.population;
+ }
+ }
+
+ splitDivergentEthnicities() {
+ for (const e of this.ethnicities.values()) {
+ if (e.population < 80 || e.diversity < 0.42) continue;
+ const newId = this.createEthnicity(e.id);
+ let moved = 0;
+ for (const a of this.agents) {
+ if (a.ethnicity !== e.id) continue;
+ const far = traitDistance(a.traits, e.averageTraits) > e.diversity * 1.12;
+ const spatial = Math.hypot(a.x - e.centroidX, a.y - e.centroidY) > this.world.size * 0.18;
+ if ((far || spatial) && this.rng.next() < 0.48) {
+ a.ethnicity = newId;
+ moved++;
+ }
+ }
+ if (moved < 24) {
+ for (const a of this.agents) if (a.ethnicity === newId) a.ethnicity = e.id;
+ this.ethnicities.delete(newId);
+ this.nextEthnicity--;
+ }
+ }
+ }
+
+ toJSON() {
+ return {
+ version: 2,
+ rngSeed: this.rng.seed,
+ year: this.year,
+ deaths: this.deaths,
+ nextEthnicity: this.nextEthnicity,
+ nextCity: this.nextCity,
+ maxAgents: this.maxAgents,
+ world: {
+ size: this.world.size,
+ terrain: packArray(this.world.terrain),
+ resource: packArray(this.world.resource),
+ regen: packArray(this.world.regen),
+ move: packArray(this.world.move),
+ fertility: packArray(this.world.fertility),
+ mineral: packArray(this.world.mineral),
+ pheromone: packArray(this.world.pheromone),
+ tradeRoute: packArray(this.world.tradeRoute),
+ farmland: packArray(this.world.farmland),
+ cityPull: packArray(this.world.cityPull),
+ city: packArray(this.world.city),
+ pressure: packArray(this.world.pressure)
+ },
+ agents: this.agents,
+ ethnicities: [...this.ethnicities.values()].map(e => ({
+ id: e.id,
+ parent: e.parent,
+ born: e.born,
+ color: e.color
+ })),
+ cities: this.cities.map(c => ({
+ ...c,
+ ethnicityComposition: [...c.ethnicityComposition],
+ tradeLinks: [...c.tradeLinks]
+ }))
+ };
+ }
+
+ static fromJSON(state) {
+ const sim = new Simulation(state.world.size, 0);
+ sim.rng.seed = state.rngSeed >>> 0;
+ sim.year = state.year || 0;
+ sim.deaths = state.deaths || 0;
+ sim.nextEthnicity = state.nextEthnicity || 1;
+ sim.nextCity = state.nextCity || 1;
+ sim.maxAgents = state.maxAgents || 30000;
+
+ sim.world.terrain.set(unpackArray(state.world.terrain, Uint8Array));
+ sim.world.resource.set(unpackArray(state.world.resource, Float32Array));
+ sim.world.regen.set(unpackArray(state.world.regen, Float32Array));
+ sim.world.move.set(unpackArray(state.world.move, Float32Array));
+ sim.world.fertility.set(unpackArray(state.world.fertility, Float32Array));
+ sim.world.mineral.set(unpackArray(state.world.mineral, Float32Array));
+ sim.world.pheromone.set(unpackArray(state.world.pheromone, Float32Array));
+ sim.world.tradeRoute.set(state.world.tradeRoute ? unpackArray(state.world.tradeRoute, Uint8Array) : new Uint8Array(sim.world.count));
+ sim.world.farmland.set(state.world.farmland ? unpackArray(state.world.farmland, Float32Array) : new Float32Array(sim.world.count));
+ sim.world.cityPull.set(state.world.cityPull ? unpackArray(state.world.cityPull, Float32Array) : new Float32Array(sim.world.count));
+ sim.world.city.set(unpackArray(state.world.city, Int32Array));
+ sim.world.pressure.set(state.world.pressure ? unpackArray(state.world.pressure, Float32Array) : new Float32Array(sim.world.count));
+
+ sim.agents = state.agents || [];
+ sim.ethnicities = new Map((state.ethnicities || []).map(e => [e.id, {
+ id: e.id,
+ parent: e.parent,
+ born: e.born,
+ population: 0,
+ diversity: 0,
+ color: e.color,
+ centroidX: 0,
+ centroidY: 0
+ }]));
+ sim.cities = (state.cities || []).map(c => ({
+ id: c.id,
+ x: c.x,
+ y: c.y,
+ population: c.population || 0,
+ storedResources: c.storedResources || 0,
+ ethnicityComposition: new Map(c.ethnicityComposition || []),
+ pheromoneOutput: c.pheromoneOutput || 0,
+ agriculturalRadius: c.agriculturalRadius || 2,
+ tradeLinks: new Set(c.tradeLinks || []),
+ activeVisitors: 0,
+ age: c.age || 0,
+ strength: c.strength || 1
+ }));
+ sim.tradeLinks = [];
+ sim.rebuildOccupancy();
+ sim.updateTradeRoutes();
+ sim.updateEthnicStats();
+ return sim;
+ }
+}
+
+function render() {
+ const start = performance.now();
+ const w = sim.world;
+ const size = w.size;
+ const image = ctx.createImageData(size, size);
+ const data = image.data;
+ const mode = els.viewMode.value;
+
+ for (let i = 0; i < w.count; i++) {
+ let color;
+ if (mode === "resources") {
+ const v = clamp(w.resource[i] / 30, 0, 1);
+ color = mix([28, 36, 40], [107, 188, 85], v);
+ } else if (mode === "pheromone") {
+ const v = clamp(w.pheromone[i] / 9, 0, 1);
+ color = w.tradeRoute[i] ? mix([90, 68, 44], [255, 218, 91], clamp(w.tradeRoute[i] / 40, 0, 1)) : mix(terrainInfo[w.terrain[i]].color, [232, 193, 75], v);
+ } else if (mode === "pressure") {
+ const v = clamp(w.pressure[i] / 12, 0, 1);
+ color = mix(terrainInfo[w.terrain[i]].color, [210, 73, 67], v);
+ } else if (mode === "cities") {
+ color = w.city[i] >= 0 ? mix([74, 105, 58], [226, 198, 121], clamp(w.farmland[i], 0, 1)) : terrainInfo[w.terrain[i]].color;
+ } else {
+ color = terrainInfo[w.terrain[i]].color;
+ }
+ const p = i * 4;
+ data[p] = color[0];
+ data[p + 1] = color[1];
+ data[p + 2] = color[2];
+ data[p + 3] = 255;
+ }
+
+ ctx.putImageData(image, 0, 0);
+ drawTradeLinks();
+ drawAgentsAndCities(mode);
+ els.frameCost.textContent = `${Math.round(performance.now() - start)}ms`;
+}
+
+function drawTradeLinks() {
+ const w = sim.world;
+ ctx.save();
+ ctx.globalAlpha = 0.62;
+ ctx.fillStyle = "#d9b650";
+ for (let i = 0; i < w.count; i++) {
+ if (w.tradeRoute[i]) ctx.fillRect(i % w.size, Math.floor(i / w.size), 1, 1);
+ }
+ if (!sim.tradeLinks.length) {
+ ctx.restore();
+ return;
+ }
+ for (const link of sim.tradeLinks) {
+ ctx.globalAlpha = clamp(0.22 + link.strength * 0.75, 0.25, 0.9);
+ ctx.fillStyle = "#ffd75a";
+ for (const tile of link.path || []) {
+ ctx.fillRect(tile % w.size, Math.floor(tile / w.size), 1, 1);
+ }
+ }
+ ctx.restore();
+}
+
+function drawAgentsAndCities(mode) {
+ ctx.save();
+ for (const city of sim.cities) {
+ const radius = cityRenderRadius(city);
+ ctx.fillStyle = "#f2d786";
+ ctx.globalAlpha = 0.88;
+ ctx.fillRect(city.x - radius, city.y - radius, radius * 2 + 1, radius * 2 + 1);
+ }
+ ctx.globalAlpha = 1;
+
+ for (const a of sim.agents) {
+ if (mode === "ethnicity") {
+ const e = sim.ethnicities.get(a.ethnicity);
+ if (!e) continue;
+ ctx.fillStyle = `rgb(${e.color[0]},${e.color[1]},${e.color[2]})`;
+ ctx.fillRect(a.x, a.y, 1, 1);
+ } else {
+ ctx.fillStyle = "#eeeccf";
+ ctx.fillRect(a.x, a.y, 1, 1);
+ }
+ }
+
+ for (const city of sim.cities) {
+ ctx.fillStyle = "#fff2b5";
+ ctx.fillRect(city.x, city.y, 1, 1);
+ }
+ ctx.restore();
+}
+
+function cityRenderRadius(city) {
+ return clamp(Math.floor(Math.sqrt(city.population) / 18), 1, 5);
+}
+
+function showTooltip(event) {
+ const rect = els.canvas.getBoundingClientRect();
+ const x = Math.floor((event.clientX - rect.left) / rect.width * sim.world.size);
+ const y = Math.floor((event.clientY - rect.top) / rect.height * sim.world.size);
+ hoverState = {
+ x,
+ y,
+ left: event.clientX - rect.left + 16,
+ top: event.clientY - rect.top + 16,
+ width: rect.width,
+ height: rect.height
+ };
+ renderTooltip();
+}
+
+function renderTooltip() {
+ if (!hoverState) return;
+ const x = hoverState.x;
+ const y = hoverState.y;
+ if (x < 0 || y < 0 || x >= sim.world.size || y >= sim.world.size) {
+ hideTooltip();
+ return;
+ }
+
+ const w = sim.world;
+ const i = w.idx(x, y);
+ const agent = findAgentAt(x, y);
+ const city = w.city[i] >= 0 ? sim.cities.find(c => c.id === w.city[i]) : null;
+ const terrain = terrainInfo[w.terrain[i]];
+ const habitat = agent ? ((agent.traits.oceanic ?? 0) >= 0.45 ? "Oceanic" : "Continental") : "";
+ const ethnicity = agent ? sim.ethnicities.get(agent.ethnicity) : null;
+ const waterInfluence = waterInfluenceAt(w, x, y).toFixed(2);
+ const cityEthnicity = city ? dominantComposition(city.ethnicityComposition) : null;
+
+ els.tooltip.innerHTML = `
+ ${agent ? "Agent group" : terrain.name}
+ Tile${x}, ${y}
+ Terrain${terrain.name}
+ Resources${w.resource[i].toFixed(1)}
+ Fertility${w.fertility[i].toFixed(2)}
+ Water influence${waterInfluence}
+ Minerals${w.mineral[i].toFixed(2)}
+ Pheromone${w.pheromone[i].toFixed(1)}
+ Pressure${w.pressure[i].toFixed(0)}
+ ${w.tradeRoute[i] ? `Route${w.tradeRoute[i]}` : ""}
+ ${city ? `City#${city.id}` : ""}
+ ${city ? `Urban pop${city.population.toLocaleString()}` : ""}
+ ${city ? `Food stock${city.storedResources.toFixed(1)}` : ""}
+ ${city ? `Farmland radius${city.agriculturalRadius}` : ""}
+ ${city ? `Trade links${city.tradeLinks.size}` : ""}
+ ${cityEthnicity ? `City majorityE${cityEthnicity}` : ""}
+ ${agent ? `EthnicityE${agent.ethnicity}` : ""}
+ ${agent ? `Habitat${habitat} ${(agent.traits.oceanic ?? 0).toFixed(2)}` : ""}
+ ${agent ? `Sedentary${getSedentary(agent.traits).toFixed(2)}` : ""}
+ ${agent ? `Ethnocentrism${getEthnocentrism(agent.traits).toFixed(2)}` : ""}
+ ${agent ? `Stored${agent.resources.toFixed(1)}` : ""}
+ ${ethnicity ? `Lineage pop${ethnicity.population}` : ""}
+ `;
+ els.tooltip.hidden = false;
+ const left = Math.min(hoverState.left, hoverState.width - 280);
+ const top = Math.min(hoverState.top, hoverState.height - 230);
+ els.tooltip.style.left = `${Math.max(8, left)}px`;
+ els.tooltip.style.top = `${Math.max(8, top)}px`;
+}
+
+function hideTooltip() {
+ hoverState = null;
+ els.tooltip.hidden = true;
+}
+
+function findAgentAt(x, y) {
+ const directIndex = sim.world.idx(x, y);
+ const directCounts = sim.tileEthnicities.get(directIndex);
+ if (!directCounts) return null;
+ for (const a of sim.agents) {
+ if (a.x === x && a.y === y) return a;
+ }
+ return null;
+}
+
+function waterInfluenceAt(world, x, y) {
+ let score = 0;
+ for (let dy = -3; dy <= 3; dy++) {
+ for (let dx = -3; dx <= 3; dx++) {
+ const d = Math.abs(dx) + Math.abs(dy);
+ if (!d || d > 3) continue;
+ const tx = x + dx;
+ const ty = y + dy;
+ if (tx < 0 || ty < 0 || tx >= world.size || ty >= world.size) continue;
+ if (world.terrain[world.idx(tx, ty)] === Terrain.WATER) score += (4 - d) / 4;
+ }
+ }
+ return score;
+}
+
+function updateStats(force = false) {
+ const now = performance.now();
+ if (!force && now - lastStatsAt < 300) return;
+ lastStatsAt = now;
+ const livingEthnicities = [...sim.ethnicities.values()].filter(e => e.population > 0);
+ const urbanPopulation = sim.cities.reduce((sum, c) => sum + c.population, 0);
+ els.year.textContent = sim.year.toLocaleString();
+ els.population.textContent = Math.floor(sim.agents.length + urbanPopulation).toLocaleString();
+ els.activeGroups.textContent = sim.agents.length.toLocaleString();
+ els.urbanPopulation.textContent = Math.floor(urbanPopulation).toLocaleString();
+ els.ethnicities.textContent = livingEthnicities.length.toLocaleString();
+ els.cities.textContent = sim.cities.length.toLocaleString();
+ els.routes.textContent = sim.tradeLinks.length.toLocaleString();
+ els.deaths.textContent = sim.deaths.toLocaleString();
+
+ const top = livingEthnicities.sort((a, b) => b.population - a.population).slice(0, 9);
+ els.ethnicityList.innerHTML = top.map(e => (
+ `` +
+ `E${e.id} pop ${e.population.toLocaleString()} div ${e.diversity.toFixed(2)} parent ${e.parent || "-"}`
+ )).join("");
+}
+
+function setLegend() {
+ const mode = els.viewMode.value;
+ if (mode === "terrain") {
+ els.legend.innerHTML = terrainInfo.map(t => `${t.name}`).join("");
+ } else if (mode === "ethnicity") {
+ els.legend.innerHTML = "Color = inherited ethnicity. New colors appear only by recorded splits or assimilation.";
+ } else if (mode === "pheromone") {
+ els.legend.innerHTML = "Pheromone trails and permanent trade routes";
+ } else if (mode === "pressure") {
+ els.legend.innerHTML = "High local population pressure";
+ } else if (mode === "cities") {
+ els.legend.innerHTML = "City nodes, farmland radius, and stored urban population";
+ } else {
+ els.legend.innerHTML = "Regenerating local resource stock";
+ }
+}
+
+function loop() {
+ const steps = running ? Number(els.speed.value) : 0;
+ for (let i = 0; i < steps; i++) sim.step();
+ if (running || frame % 8 === 0) {
+ render();
+ updateStats();
+ renderTooltip();
+ }
+ frame++;
+ requestAnimationFrame(loop);
+}
+
+function reset() {
+ const size = Number(els.worldSize.value);
+ const count = Number(els.agentCount.value);
+ els.canvas.width = size;
+ els.canvas.height = size;
+ sim = new Simulation(size, count);
+ setLegend();
+ render();
+ updateStats(true);
+}
+
+function clamp(v, min, max) {
+ return Math.max(min, Math.min(max, v));
+}
+
+function smoothstep(t) {
+ return t * t * (3 - 2 * t);
+}
+
+function getSedentary(traits) {
+ return traits.sedentary ?? traits.settling ?? 0.35;
+}
+
+function getEthnocentrism(traits) {
+ if (traits.ethnocentrism !== undefined) return traits.ethnocentrism;
+ const same = traits.sameAttraction ?? 0.35;
+ const avoid = traits.foreignAvoidance ?? 0.22;
+ return clamp((same + avoid * 1.5) * 0.5, 0, 1.2);
+}
+
+function mutateTraits(traits, rng, amount) {
+ return {
+ mobility: clamp(traits.mobility + rng.range(-amount, amount), 0.02, 1),
+ resourceAttraction: clamp(traits.resourceAttraction + rng.range(-amount, amount), 0.05, 1.2),
+ assimilation: clamp(traits.assimilation + rng.range(-amount, amount), 0, 0.75),
+ ethnocentrism: clamp(getEthnocentrism(traits) + rng.range(-amount, amount), 0, 1.2),
+ reproductionThreshold: clamp(traits.reproductionThreshold + rng.range(-amount * 16, amount * 16), 12, 48),
+ oceanic: clamp((traits.oceanic ?? 0.12) + rng.range(-amount, amount), 0, 1),
+ sedentary: clamp(getSedentary(traits) + rng.range(-amount, amount), 0, 1)
+ };
+}
+
+function blendTraits(a, b, t) {
+ return {
+ mobility: lerp(a.mobility, b.mobility, t),
+ resourceAttraction: lerp(a.resourceAttraction, b.resourceAttraction, t),
+ assimilation: lerp(a.assimilation, b.assimilation, t),
+ ethnocentrism: lerp(getEthnocentrism(a), getEthnocentrism(b), t),
+ reproductionThreshold: lerp(a.reproductionThreshold, b.reproductionThreshold, t),
+ oceanic: lerp(a.oceanic ?? 0.12, b.oceanic ?? 0.12, t),
+ sedentary: lerp(getSedentary(a), getSedentary(b), t)
+ };
+}
+
+function emptyTraitSums() {
+ return { mobility: 0, resourceAttraction: 0, assimilation: 0, ethnocentrism: 0, reproductionThreshold: 0, oceanic: 0, sedentary: 0 };
+}
+
+function addTraits(sum, traits) {
+ sum.mobility += traits.mobility;
+ sum.resourceAttraction += traits.resourceAttraction;
+ sum.assimilation += traits.assimilation;
+ sum.ethnocentrism += getEthnocentrism(traits);
+ sum.reproductionThreshold += traits.reproductionThreshold / 48;
+ sum.oceanic += traits.oceanic ?? 0.12;
+ sum.sedentary += getSedentary(traits);
+}
+
+function averageTraits(sum, count) {
+ return {
+ mobility: sum.mobility / count,
+ resourceAttraction: sum.resourceAttraction / count,
+ assimilation: sum.assimilation / count,
+ ethnocentrism: sum.ethnocentrism / count,
+ reproductionThreshold: (sum.reproductionThreshold / count) * 48,
+ oceanic: sum.oceanic / count,
+ sedentary: sum.sedentary / count
+ };
+}
+
+function addBirthsToComposition(composition, births) {
+ const dominant = dominantComposition(composition);
+ if (!dominant) return;
+ composition.set(dominant, (composition.get(dominant) || 0) + births);
+}
+
+function removeFromComposition(composition, loss) {
+ let remaining = loss;
+ const total = [...composition.values()].reduce((sum, value) => sum + value, 0);
+ if (!total) return;
+ for (const [id, count] of [...composition]) {
+ const removed = Math.min(count, Math.ceil(loss * (count / total)));
+ composition.set(id, Math.max(0, count - removed));
+ remaining -= removed;
+ if (composition.get(id) <= 0) composition.delete(id);
+ if (remaining <= 0) break;
+ }
+}
+
+function dominantComposition(composition) {
+ let bestId = null;
+ let bestCount = 0;
+ for (const [id, count] of composition) {
+ if (count > bestCount) {
+ bestId = id;
+ bestCount = count;
+ }
+ }
+ return bestId;
+}
+
+function traitDistance(a, b) {
+ return Math.abs(a.mobility - b.mobility) +
+ Math.abs(a.resourceAttraction - b.resourceAttraction) +
+ Math.abs(a.assimilation - b.assimilation) +
+ Math.abs(getEthnocentrism(a) - getEthnocentrism(b)) +
+ Math.abs(a.reproductionThreshold - b.reproductionThreshold) / 48 +
+ Math.abs((a.oceanic ?? 0.12) - (b.oceanic ?? 0.12)) +
+ Math.abs(getSedentary(a) - getSedentary(b));
+}
+
+function hslToRgb(h, s, l) {
+ const hue = (p, q, t) => {
+ if (t < 0) t += 1;
+ if (t > 1) t -= 1;
+ if (t < 1 / 6) return p + (q - p) * 6 * t;
+ if (t < 1 / 2) return q;
+ if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
+ return p;
+ };
+ const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
+ const p = 2 * l - q;
+ return [
+ Math.round(hue(p, q, h + 1 / 3) * 255),
+ Math.round(hue(p, q, h) * 255),
+ Math.round(hue(p, q, h - 1 / 3) * 255)
+ ];
+}
+
+function mix(a, b, t) {
+ return [
+ Math.round(lerp(a[0], b[0], t)),
+ Math.round(lerp(a[1], b[1], t)),
+ Math.round(lerp(a[2], b[2], t))
+ ];
+}
+
+function lerp(a, b, t) {
+ return a + (b - a) * t;
+}
+
+function packArray(typedArray) {
+ const bytes = new Uint8Array(typedArray.buffer);
+ let binary = "";
+ const chunkSize = 8192;
+ for (let i = 0; i < bytes.length; i += chunkSize) {
+ binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize));
+ }
+ return {
+ type: typedArray.constructor.name,
+ length: typedArray.length,
+ data: btoa(binary)
+ };
+}
+
+function unpackArray(payload, TypedArray) {
+ if (Array.isArray(payload)) return new TypedArray(payload);
+ const binary = atob(payload.data);
+ const bytes = new Uint8Array(binary.length);
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
+ return new TypedArray(bytes.buffer, 0, payload.length);
+}
+
+function saveWorld() {
+ try {
+ localStorage.setItem("civil-emergence-save", JSON.stringify(sim));
+ } catch (error) {
+ console.warn("Save failed", error);
+ }
+}
+
+function loadWorld() {
+ const raw = localStorage.getItem("civil-emergence-save");
+ if (!raw) return;
+ try {
+ const state = JSON.parse(raw);
+ sim = Simulation.fromJSON(state);
+ els.worldSize.value = String(sim.world.size);
+ els.canvas.width = sim.world.size;
+ els.canvas.height = sim.world.size;
+ setLegend();
+ render();
+ updateStats(true);
+ } catch (error) {
+ console.warn("Load failed", error);
+ }
+}
+
+els.toggleRun.addEventListener("click", () => {
+ running = !running;
+ els.toggleRun.textContent = running ? "Pause" : "Run";
+});
+els.stepOnce.addEventListener("click", () => {
+ sim.step();
+ render();
+ updateStats(true);
+ renderTooltip();
+});
+els.resetWorld.addEventListener("click", reset);
+els.saveWorld.addEventListener("click", saveWorld);
+els.loadWorld.addEventListener("click", loadWorld);
+els.clearSave.addEventListener("click", () => localStorage.removeItem("civil-emergence-save"));
+els.worldSize.addEventListener("change", reset);
+els.agentCount.addEventListener("change", reset);
+els.viewMode.addEventListener("change", () => {
+ setLegend();
+ render();
+});
+els.canvas.addEventListener("mousemove", showTooltip);
+els.canvas.addEventListener("mouseleave", hideTooltip);
+
+reset();
+loop();
diff --git a/styles.css b/styles.css
new file mode 100644
index 0000000..3c9d1d2
--- /dev/null
+++ b/styles.css
@@ -0,0 +1,288 @@
+:root {
+ color-scheme: dark;
+ --bg: #111315;
+ --panel: #1b1f22;
+ --panel-2: #22282c;
+ --line: #31383d;
+ --text: #eef1ed;
+ --muted: #a8b1aa;
+ --accent: #69b578;
+ --accent-2: #e3b341;
+ --danger: #d35f54;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html,
+body {
+ width: 100%;
+ height: 100%;
+ margin: 0;
+ overflow: hidden;
+}
+
+body {
+ background: var(--bg);
+ color: var(--text);
+ font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+}
+
+button,
+input,
+select {
+ font: inherit;
+}
+
+.app {
+ display: grid;
+ grid-template-columns: minmax(280px, 340px) minmax(0, 1fr);
+ width: 100%;
+ height: 100%;
+}
+
+.sidebar {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+ min-width: 0;
+ padding: 14px;
+ overflow: auto;
+ border-right: 1px solid var(--line);
+ background: #16191b;
+}
+
+.brand h1 {
+ margin: 0 0 6px;
+ font-size: 24px;
+ line-height: 1.05;
+ letter-spacing: 0;
+}
+
+.brand p {
+ margin: 0;
+ color: var(--muted);
+ font-size: 13px;
+ line-height: 1.35;
+}
+
+.panel {
+ padding: 12px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: var(--panel);
+}
+
+.row {
+ display: grid;
+ grid-template-columns: 1fr 1fr 1fr;
+ gap: 8px;
+ margin-bottom: 12px;
+}
+
+button,
+select {
+ min-height: 34px;
+ border: 1px solid #3d464b;
+ border-radius: 6px;
+ background: var(--panel-2);
+ color: var(--text);
+}
+
+button {
+ cursor: pointer;
+}
+
+button:hover,
+select:hover {
+ border-color: #59656b;
+}
+
+button.primary {
+ background: #254b30;
+ border-color: #3d794b;
+}
+
+.controls label {
+ display: grid;
+ gap: 6px;
+ margin-top: 11px;
+ color: var(--muted);
+ font-size: 12px;
+}
+
+input[type="range"] {
+ width: 100%;
+ accent-color: var(--accent);
+}
+
+select {
+ width: 100%;
+ padding: 0 9px;
+}
+
+.stats dl {
+ display: grid;
+ gap: 7px;
+ margin: 0;
+}
+
+.stats div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+ min-height: 22px;
+}
+
+.stats dt {
+ color: var(--muted);
+ font-size: 12px;
+}
+
+.stats dd {
+ margin: 0;
+ font-variant-numeric: tabular-nums;
+ font-weight: 700;
+}
+
+.ledger {
+ min-height: 0;
+}
+
+.ledger h2 {
+ margin: 0 0 10px;
+ font-size: 14px;
+ letter-spacing: 0;
+}
+
+.ledger ol {
+ display: grid;
+ gap: 7px;
+ max-height: 260px;
+ margin: 0;
+ padding-left: 22px;
+ overflow: auto;
+}
+
+.ledger li {
+ color: var(--muted);
+ font-size: 12px;
+ line-height: 1.35;
+}
+
+.lineage-chip {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ margin-right: 7px;
+ border-radius: 2px;
+ vertical-align: -1px;
+}
+
+.sim {
+ position: relative;
+ display: grid;
+ place-items: center;
+ min-width: 0;
+ min-height: 0;
+ padding: 16px;
+ background: #101214;
+}
+
+canvas {
+ display: block;
+ width: min(calc(100vh - 32px), calc(100vw - 380px));
+ height: min(calc(100vh - 32px), calc(100vw - 380px));
+ max-width: 100%;
+ max-height: 100%;
+ border: 1px solid #293035;
+ background: #0b0d0f;
+ image-rendering: pixelated;
+}
+
+.legend {
+ position: absolute;
+ right: 24px;
+ bottom: 20px;
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ gap: 8px;
+ max-width: min(560px, calc(100% - 48px));
+ padding: 8px 10px;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: rgba(20, 23, 25, 0.92);
+ color: var(--muted);
+ font-size: 11px;
+}
+
+.legend span {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+}
+
+.legend i {
+ width: 12px;
+ height: 12px;
+ border-radius: 2px;
+}
+
+.tooltip {
+ position: absolute;
+ z-index: 3;
+ min-width: 180px;
+ max-width: 260px;
+ padding: 8px 10px;
+ border: 1px solid #435057;
+ border-radius: 6px;
+ background: rgba(15, 18, 20, 0.96);
+ color: var(--text);
+ font-size: 11px;
+ line-height: 1.35;
+ pointer-events: none;
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
+}
+
+.tooltip strong {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 12px;
+}
+
+.tooltip span {
+ display: flex;
+ justify-content: space-between;
+ gap: 14px;
+ color: var(--muted);
+}
+
+@media (max-width: 820px) {
+ body {
+ overflow: auto;
+ }
+
+ .app {
+ grid-template-columns: 1fr;
+ height: auto;
+ min-height: 100%;
+ }
+
+ .sidebar {
+ border-right: 0;
+ border-bottom: 1px solid var(--line);
+ }
+
+ .sim {
+ min-height: 70vh;
+ padding: 10px;
+ }
+
+ canvas {
+ width: min(calc(100vw - 20px), 92vh);
+ height: min(calc(100vw - 20px), 92vh);
+ }
+}