← Back

schism devlog - 06/04

Starting something new

I started a new project called schism. It is a religion evolution sim. You pick a starting region or climate, hit run, and watch belief systems emerge, splinter, and die out over generations. Conway's Game of Life, but for religions instead of cells.

The reason I think it is worth doing is that the hard part here isn't the math. There are basically no assets, and the whole thing is shaped like a pure function: a world state goes in, a tick of rules runs, a new world state comes out, generation after generation. That is not a game engine's job, it is just code. So there is no Unity anywhere in this, just a headless engine I can run from the CLI and attach a frontend to later. The engine never imports anything about rendering, it just produces the history of what happened, and something else decides how to draw it down the line.

I went with Rust because the tick is a tight loop over a lot of people that I want to run hundreds of times while I tune the rules. I want the speed and I want the type system holding my hand on that loop.

How I think it will work

The thing I keep reminding myself is that the believable part is the whole project, not the math. A religion sim fails the second it reads as random. If a religion splits and you can't point at the split and say "that happened because the famine hit," then it is just a random graph wearing religious vocabulary.

So the plan is to ground the rules in the actual ecology-of-religion research, the angry god hypothesis basically: harsh, disaster prone environments tend to breed angrier, more moralizing gods, and stable abundant ones breed gentler ones. Disasters and fortunes feed the transition rules, so a schism reads as caused instead of arbitrary. I get believability cheaply by borrowing it from real findings instead of inventing rules from scratch.

Two guardrails I want to hold from the start. Keep the trait set small, religions will eventually have a handful of belief axes and not dozens, otherwise the family tree turns into an unreadable hairball and I lose the ability to tell why anything happened. And judge it on a plain text readout first, because drawing the graph too early only makes arbitrariness prettier.

What I have built so far

Right now the whole world is two slotmaps, one of religions and one of adherents, and each generation runs a tick over them. Honestly most of the sim at this stage is about the people, not the religions. The religions are barely more than nodes in a tree so far.

The world is two slotmaps

Everything lives in slotmaps keyed by typed keys, so religions and adherents reference each other by key instead of by pointer. An adherent holds the key of the religion it follows, and a religion holds the key of its parent.

new_key_type! {
    pub struct ReligionKey;
    pub struct AdherentKey;
}

pub struct Simulation {
    religions: SlotMap<ReligionKey, Religion>,
    adherents: SlotMap<AdherentKey, Adherent>,
    config: SimulationConfig,
    rng: SmallRng,
}

It starts with one root religion and ten thousand adherents all following it.

People (adherents)

This is where most of the actual simulation happens right now.

pub struct Adherent {
    /// likelihood this person questions their current religion
    pub heterodoxy: UnitInterval,

    pub age: u8,

    /// dead or alive, soft delete
    pub status: AdherentStatus,

    /// religion this person follows
    pub religion: ReligionKey,
}

Each tick a person ages by one, then maybe dies and maybe gives birth. Mortality and fertility are both age banded, so a 30 year old is far more likely to have a kid than a 70 year old, and far less likely to die. Death is a soft delete, I just flip the status to Dead and skip them on future ticks instead of removing them from the slotmap.

Belief passes down by inheritance. When someone gives birth, the child is born straight into the parent's religion:

if adherent.gave_birth(&mut self.rng) {
    births.push(Adherent::new(adherent.religion));
}

So the way a religion grows is mostly just its adherents outbreeding the others, not conversion.

UnitInterval, my favorite bit so far

Probabilities are everywhere in this thing, mortality, fertility, heterodoxy, conversion chance, and every one of them has to stay between 0 and 1. So instead of passing raw f32s around and hoping, I made a newtype that can't hold an invalid probability.

/// a float guaranteed to be between 0 and 1
pub struct UnitInterval(f32);

impl UnitInterval {
    pub const fn new(value: f32) -> Self {
        assert!(value >= 0.0 && value <= 1.0);
        Self(value)
    }
}

impl AddAssign for UnitInterval {
    /// saturates at 1.0 so the [0, 1] invariant always holds
    fn add_assign(&mut self, rhs: Self) {
        *self = UnitInterval::new((self.0 + rhs.0).clamp(0.0, 1.0));
    }
}

The add and subtract operators clamp at the edges, so when an adherent's heterodoxy drifts up over a long life it just saturates at 1.0 instead of overflowing the range. Small thing, but it means a whole class of bug can't happen.

Religions as a tree

A religion is barely anything yet. A name, an age in generations, a status, and a pointer to its parent.

/// modelled as a tree structure
pub struct Religion {
    pub name: String,

    /// age in generations
    pub age: u32,

    pub status: ReligionStatus,

    /// parent religion node
    pub parent: Option<ReligionKey>,
}

The parent key is the important part. Every religion points back at the one it split from, so the entire history is a tree I can walk later to render the family graph. The root has no parent.

The schism rule, the naive version

Here is the current model, and it is deliberately naive. A religion can split if it is big enough and its adherents are heterodox enough on average, weighted up by how many of them are very heterodox and by the population size.

pub fn should_schism(&self, adherents: &[&Adherent], rng: &mut SmallRng) -> bool {
    if adherents.len() < 50 {
        return false;
    }

    let avg_heterodoxy = adherents
        .iter()
        .map(|adherent| adherent.heterodoxy.value() as f64)
        .sum::<f64>()
        / adherents.len() as f64;

    let high_heterodoxy_share = adherents
        .iter()
        .filter(|adherent| adherent.heterodoxy.value() as f64 > 0.7)
        .count() as f64
        / adherents.len() as f64;

    let population_factor = (adherents.len() as f64 / 1000.0).min(1.0);

    let chance = 0.01 * avg_heterodoxy * (1.0 + high_heterodoxy_share) * population_factor;

    rng.random_bool(chance)
}

When it fires, a new sect is born as a child of the parent, and each adherent gets a chance to convert into it, scaled by their own heterodoxy, so the people who were already questioning are the ones most likely to leave.

The big honest gap: religions don't have any beliefs yet. A schism right now just spawns a new node with a generated name. The new sect doesn't actually believe anything different from its parent, it is just a different label on the tree. Making that real is the whole next phase.

What the output looks like

Running it headless against a desert for a hundred generations, schism run -e desert -n 100, dumps a small JSON-ish block per generation. Generation zero is just the root religion and its starting population:

{
  "totals": { "people": 9990, "religions": 1, "active": 1, "extinct": 0, "new_this_generation": 0 },
  "religions": [
    { "name": "Gurneyism", "adherents": 9990, "status": "active", "age": 0, "parent": "none", "new": false }
  ]
}

And here is where it gets interesting, and a little embarrassing. A hundred generations later, nobody has schismed. Not once.

{
  "totals": { "people": 521980, "religions": 1, "active": 1, "extinct": 0, "new_this_generation": 0 },
  "religions": [
    { "name": "Gurneyism", "adherents": 521980, "status": "active", "age": 0, "parent": "none", "new": false }
  ]
}

The population went from ten thousand to over half a million, but it is still one religion. That tracks with the model: everyone is born at a low baseline heterodoxy and almost never drifts far enough for the schism chance to clear, so Gurneyism just outbreeds itself forever. It is a population sim with a religion label stapled on, which is exactly the "reads as a population sim, not a religion sim" failure I expected to run into. The thresholds need tuning, but more than that, the inheritance and belief work below need to exist before any of this gets interesting.

It also makes the performance problem impossible to ignore.

Current issues

It is slow. My suspicion is that I rebuild too much from scratch every single tick. Each generation I walk every adherent and build a brand new HashMap grouping them by religion, then collect a fresh Vec of adherents for each religion to hand to the schism check.

// rebuilt from scratch every tick
let mut religion_adherents: HashMap<ReligionKey, Vec<AdherentKey>> = HashMap::new();

for (adherent_id, adherent) in &mut self.adherents {
    // ...age, death, birth...
    religion_adherents
        .entry(adherent.religion)
        .or_default()
        .push(adherent_id);
}

The run above ended at over half a million living adherents, and it only grows from there. Worse, the dead are never removed from the slotmap, they are just soft deleted, so the map I walk every single tick keeps growing without bound, dead and alive alike. That per tick reallocation over an ever larger pool adds up fast. I need a faster way. Probably keep the grouping around and update it incrementally as people are born, die, and convert, instead of throwing it away and rebuilding it from zero each tick. The print readout I turned on to grab the output above is its own separate tax too, a couple of formatted lines per generation. I want to profile it properly before committing to a fix though.

What's next

Two things, roughly in order.

Heterodoxy inheritance. Right now every newborn starts at the same fixed baseline heterodoxy and then drifts on its own random walk over its life, totally independent of its parents. So whether a family line is orthodox or heretical isn't really inherited, it gets reset every birth. I want a kid to inherit the parent's current heterodoxy scaled by some noise, so heterodox families tend to produce heterodox kids and orthodoxy actually clusters in lineages instead of being rerolled every generation.

Richer religions. This is the big one. Give religions actual belief types, a small set of axes like how angry or moralizing the god is, that can mutate during a schism and that respond to the environment. That is where the angry god hypothesis finally has somewhere to live: a famine driven split should push the new sect toward an angrier god, a stable abundant region should drift gentler. Right now the environment is just an enum that does nothing, so making the belief content real and tying it to the environment is what turns this from a population sim with religious labels into the thing I actually want.