diff --git a/FINDINGS.md b/FINDINGS.md
index 8b4dd95..13e5335 100644
--- a/FINDINGS.md
+++ b/FINDINGS.md
@@ -300,8 +300,39 @@ is hungriest (teaches no one, shares nothing). 99 bonds, 80 deaths — a
living, pairing, generational population in four distinct societies, all
from behavior weights. Nothing is scripted; the cultures are emergent.
-**Next:** reproduction on top of bonds (Phase B); crisis-recovery
-experiment (North's resilience); save/load; then the player pivot.
+## Run 14 — Reproduction, and towns anchored to the compass
+
+**Built:** children from bonds (Phase B). Death no longer auto-spawns a
+replacement — people die and are gone; bonded, fed, adult pairs conceive
+(logistic-damped toward a per-town carrying capacity, so population is a
+band, not an explosion or a collapse). Population is now genuinely
+dynamic: births vs deaths. Also anchored towns to their cardinal seeds —
+the build-centroid is now 70% weighted to the fixed compass point, so
+settlements stay planted (North founds at ~(388,139) by its (384,130)
+seed) instead of drifting to map-center where foraging paths overlap
+(West had wandered 100+ cells before).
+
+**Tuning story (three failed calibrations, logged as the lesson):**
+first pass exploded (80→211 by day 80 — young founders, nobody dying,
+every couple breeding); over-damped to fix it and the world died out
+(80→6, East extinct); the real bug was a *generational gap* — founders
+aged out of fertility before their children matured and bonded, so
+births stopped even as room opened. Fix: wide fertility window (to 85%
+of life) + generous rate (0.12/couple/day × room). Population dynamics
+are a control loop; the birth rate must track the death rate or the
+system runs away in whichever direction it's biased.
+
+**Result — reproduction sharpens the four-town thesis:** births (108) ≈
+deaths (104), population settled in a healthy band (~68–109). And the
+cultures now diverge in *demographics*, not just soul-state: North (26)
+and South (22) thrive and stay soul-clean; West shrinks (16) with debris
+creeping in; **East collapses to 4, starving (nourishment 0.00), highest
+debris — selfishness now literally fails to sustain a population.** A
+town that won't share or cooperate can't feed its people or raise a next
+generation. The most damning verdict the sim has produced.
+
+**Next:** crisis-recovery experiment (North's resilience); save/load;
+then the player pivot.
---
diff --git a/scripts/GameManager.cs b/scripts/GameManager.cs
index 6d613d7..bc8c508 100644
--- a/scripts/GameManager.cs
+++ b/scripts/GameManager.cs
@@ -19,7 +19,7 @@ public partial class GameManager : Node2D
// centroid, buildings, and granary. NPCs affiliate with a home town
// but roam freely — the space between towns is where cultures meet.
- [Export] public int TownPopulationEach = 20; // per settlement (~80 total)
+ [Export] public int TownPopulationEach = 16; // per settlement (~64 total, room to grow)
public record TownDef(string Name, Vector2 Center, SoulType Soul);
@@ -151,6 +151,15 @@ public partial class GameManager : Node2D
return count;
}
+ /// A town's fixed cardinal seed — its permanent home on the
+ /// map, unaffected by where its people happen to wander.
+ public Vector2 TownAnchor(string town)
+ {
+ foreach (var t in _towns)
+ if (t.Name == town) return t.Center;
+ return new Vector2(World.GridSize / 2f, World.GridSize / 2f);
+ }
+
public Vector2 TownCentroid(string town)
{
Vector2 sum = Vector2.Zero;
@@ -161,13 +170,13 @@ public partial class GameManager : Node2D
sum += npc.Position;
count++;
}
- if (count == 0)
- {
- foreach (var t in _towns)
- if (t.Name == town) return t.Center;
- return new Vector2(World.GridSize / 2f, World.GridSize / 2f);
- }
- return sum / count;
+ if (count == 0) return TownAnchor(town);
+
+ // Anchor-weighted: the settlement's build-center stays near its
+ // cardinal seed even as villagers roam outward to distant resources.
+ // Towns keep their place on the compass instead of drifting to the
+ // middle of the map where everyone's foraging paths overlap.
+ return (sum / count).Lerp(TownAnchor(town), 0.7f);
}
public override void _Process(double delta)
@@ -210,63 +219,102 @@ public partial class GameManager : Node2D
}
}
- // --- Mortality: generations end, and what wasn't passed on ends too --
+ // --- Mortality & birth: generations end, and new ones are born --------
+ // Death no longer auto-spawns a replacement. People die and are simply
+ // gone; children come from bonded pairs (TickReproduction). Population
+ // is now births vs deaths — a town can grow, shrink, or die out.
public int DeathsToday { get; set; }
+ public int BirthsToday { get; set; }
private int _bornCount;
private void TickMortality()
{
- var dying = new List();
- foreach (var npc in Npcs)
- {
- npc.AgeDays += 1f;
- if (npc.AgeDays >= npc.LifeExpectancyDays)
- dying.Add(npc);
- }
-
var rng = new System.Random(Seed + (int)TotalTicks);
+
foreach (var dead in Npcs.ToArray())
{
- if (!dying.Contains(dead)) continue;
+ dead.AgeDays += 1f;
+ if (dead.AgeDays < dead.LifeExpectancyDays) continue;
+
Npcs.Remove(dead);
DeathsToday++;
// Everything unshared dies with them: skills, memories, echoes.
- // What survives is what they taught, built, and stored.
+ // What survives is what they taught, built, stored — and bore.
foreach (var other in Npcs)
other.ForgetPerson(dead);
- // A youth comes of age in the same town, same culture, knowing
- // almost nothing — but raised among the living, not in a void.
- // They spawn beside the town's wisest elder, so the young begin
- // life within reach of what the community still remembers. The
- // town's knowledge is only what its living carry — but the young
- // start where that knowledge is.
- _bornCount++;
- Npc? elder = null;
- float bestKnow = -1f;
- foreach (var other in Npcs)
- if (other.HomeTown == dead.HomeTown && other.Know.Total > bestKnow)
- { bestKnow = other.Know.Total; elder = other; }
- Vector2 cradle = elder?.Position ?? TownCentroid(dead.HomeTown);
+ GD.Print($"[Passing] day {Day}: {dead.Name} dies at {dead.AgeDays:0} days " +
+ $"(knowledge {dead.Know.Total:0.00} lost).");
+ }
+ }
- var youth = new Npc
+ ///
+ /// Children come from bonds (Phase B). A bonded pair — both grown adults,
+ /// both fed, with a roof in town — may conceive once per season. The
+ /// child spawns beside its parents, inherits their town and culture, and
+ /// begins knowing almost nothing but growing up among those who do. This
+ /// is how a town persists: not by respawn-on-death, but by families.
+ ///
+ /// Rough carrying capacity per town — beds plus a margin the
+ /// land can feed. Births taper toward zero as a town fills it.
+ private const int TownCarryingCapacity = 28;
+
+ private void TickReproduction()
+ {
+ var rng = new System.Random(Seed * 7 + (int)TotalTicks);
+ var newborns = new List();
+
+ // Town populations, to damp births near carrying capacity.
+ var pop = new Dictionary();
+ foreach (var npc in Npcs)
+ pop[npc.HomeTown] = pop.GetValueOrDefault(npc.HomeTown) + 1;
+
+ foreach (var a in Npcs)
+ {
+ var b = a.Partner;
+ if (b == null) continue;
+ if (a.GetHashCode() >= b.GetHashCode()) continue; // count each pair once
+ if (!a.IsAdult || !b.IsAdult) continue;
+ if (a.Nourishment < 0.5f || b.Nourishment < 0.5f) continue; // both fed
+ // Fertile from adulthood through most of life — a wide window so
+ // generations overlap and a town isn't one bad cohort from ruin.
+ if (a.AgeDays > a.LifeExpectancyDays * 0.85f) continue;
+
+ // Logistic damping: a full town barely breeds; an emptied one
+ // (after a plague) breeds back toward capacity. This is what
+ // keeps population in a band instead of exploding or dying out.
+ float townPop = pop.GetValueOrDefault(a.HomeTown);
+ float roomFactor = Mathf.Clamp(1f - townPop / TownCarryingCapacity, 0f, 1f);
+ if (roomFactor <= 0f) continue;
+
+ // High enough to outrun the founding cohort's die-off and keep
+ // generations overlapping; logistic damping caps the ceiling so
+ // it can't explode. Deliberately generous — a shrinking town is
+ // a worse failure than a full one.
+ double dailyChance = 0.12 * roomFactor;
+ if (rng.NextDouble() > dailyChance) continue;
+
+ _bornCount++;
+ var child = new Npc
{
- Name = $"{dead.HomeTown}_{(char)('A' + _bornCount % 26)}{_bornCount + 25}",
- HomeTown = dead.HomeTown,
- Soul = dead.Soul, // raised in the culture that raised them
+ Name = $"{a.HomeTown}_{(char)('a' + _bornCount % 26)}{_bornCount + 25}",
+ HomeTown = a.HomeTown,
+ Soul = a.Soul, // raised in the culture that bore them
PersonalityModifier = (float)(rng.NextDouble() * 0.4 - 0.2),
- Position = cradle + new Vector2(rng.Next(-4, 5), rng.Next(-4, 5)),
+ Position = a.Position + new Vector2(rng.Next(-3, 4), rng.Next(-3, 4)),
AgeDays = 0f,
LifeExpectancyDays = 150 + rng.Next(0, 80),
};
- youth.Know.Capacity = 1.0f + (float)rng.NextDouble();
- Npcs.Add(youth);
-
- GD.Print($"[Passing] day {Day}: {dead.Name} dies at {dead.AgeDays:0} days " +
- $"(knowledge {dead.Know.Total:0.00} lost); {youth.Name} comes of age.");
+ child.Know.Capacity = 1.0f + (float)rng.NextDouble();
+ newborns.Add(child);
+ BirthsToday++;
+ pop[a.HomeTown] = (int)townPop + 1; // count the newborn toward capacity
+ GD.Print($"[Birth] day {Day}: {a.Name} & {b.Name} ({a.HomeTown}) → {child.Name}.");
}
+
+ Npcs.AddRange(newborns);
}
/// Where the community's feet actually are — new communal
@@ -298,6 +346,7 @@ public partial class GameManager : Node2D
foreach (var npc in Npcs)
npc.FadeEchoes(0.005f);
TickMortality();
+ TickReproduction();
StatsLogger.LogDay(this);
if (Day % 10 == 0)
GD.Print($"[WorldSim] Day {Day} complete.");
diff --git a/scripts/StatsLogger.cs b/scripts/StatsLogger.cs
index 29c22fb..aeb3c5c 100644
--- a/scripts/StatsLogger.cs
+++ b/scripts/StatsLogger.cs
@@ -21,7 +21,7 @@ public static class StatsLogger
"avg_fatigue,avg_nourish,avg_health,carried," +
"gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
"shelters_done,ruins,avg_cond,upkeep_stock,burn_per_day,modules,granary_food," +
- "avg_debris,refusals,incomplete_sites,avg_knowledge,deaths";
+ "avg_debris,refusals,incomplete_sites,avg_knowledge,deaths,births,population";
private const string NpcHeader =
"day,npc,name,soul,town,dominant,gather_ticks,build_ticks,trade_ticks,rest_ticks,social_ticks," +
@@ -153,7 +153,9 @@ public static class StatsLogger
float knowSum = 0f;
foreach (var npc in gm.Npcs) knowSum += npc.Know.Total;
row.Append((knowSum / count).ToString("0.000", ci)).Append(',');
- row.Append(gm.DeathsToday);
+ row.Append(gm.DeathsToday).Append(',');
+ row.Append(gm.BirthsToday).Append(',');
+ row.Append(gm.Npcs.Count);
File.AppendAllText(_path, row + "\n");
@@ -194,5 +196,6 @@ public static class StatsLogger
TradeSystem.TradesToday = 0;
TradeSystem.RefusalsToday = 0;
gm.DeathsToday = 0;
+ gm.BirthsToday = 0;
}
}