theor

solutions looking for problems

Moirai: a language for procedurally generating world histories


I love procedural generation, and I wanted to generate a world history like Dwarf Fortress does: a timeline of facts, people, events. I ended up with Moirai, a procedural narrative and world-simulation engine driven by a custom language, because every problem is a compiler/interpreter if I try hard enough to solve it.

Code and result sample

What is Moirai?

A chronological record of significant events (such as those affecting a nation or institution) often including an explanation of their causes (Merriam-Webster)

Moirai is a language to describe entities and events happening to them, a runtime to execute the language, a UI to explore the data, and an LSP server for VS Code, all written in C# with minimal dependencies. The UI is a Svelte frontend backed either by an ASP.NET Core API or by a WASM build of the engine, and I intend to test it in Unity too.

If you want to give the WASM build a try, it’s hosted on GitHub Pages: theor.github.io/Moirai/ and the repo with desktop releases is here: github.com/theor/Moirai.

Site

The language

The language is a mix of Rust, C# and F# - with room for improvement, as the history of the project shows through the syntax.

A sample world

Entities

Let’s start with artifacts. First we need to define the entity, with its properties.

sample.moi
entity Artifact {
    // all entities have an implicit name
    // prop name: string
    prop date_created: number
}

Events

Then we can declare an event that will happen, on average, once every 15 years. The UI already shows records:

First items

sample.moi
entity Artifact {
    // all entities have an implicit name
    // prop name: string
    prop date_created: number
}

@frequency(1, PerXYear, 15)
event artifact_created() {
    // the parameter is the name, using string interpolation.
    create Artifact $a: 'A{random(1000)}'
    set $a.date_created = #Time.year
    // records are the prose shown in the UI
    record('Artifact {$a.name} has been created')
}

Random tables

The names are boring; let’s add a couple of tables, which can be used to pick random values like you would when playing a TTRPG.

Name interpolation
Who doesn’t want a Keychain of Destiny?

sample.moi
entity Artifact {
    prop date_created: number
}

table NamePrefix { 'Sword', 'Spear', 'Boots', 
                   'Keychain', 'Grimoire'}
table NameSuffix { 'Destiny', 'Doom', 'Solace', 'Joy'}

@frequency(1, PerXYear, 15)
event artifact_created() {
    create Artifact $a: '{roll(NamePrefix)} of {roll(NameSuffix)}'
    set $a.date_created = #Time.year
    record('Artifact {$a.name} has been created')
}

Links between entities

Items need an owner, so let’s add people.

sample.moi
entity Artifact {
    prop date_created: number
    prop owner: Person
}
entity Person {
    prop birth_date: number
}

table NamePrefix { 'Sword', 'Spear', 'Boots', 
                   'Keychain', 'Grimoire'}
table NameSuffix { 'Destiny', 'Doom', 'Solace', 'Joy'}

@frequency(1, PerXYear, 15)
event artifact_created() {
    pick Person $p
    create Artifact $a: '{roll(NamePrefix)} of {roll(NameSuffix)}'
    // Time is an always present entity. #Time is the syntax to access a singleton
    set $a.date_created = #Time.year
    
    set $a.owner = $p
    record('Artifact {$a.name} has been created. {$p.name} owns it.')
}

Creating people

Because no person exists and the pick call is unconditional, the event will exit early. picks can be made conditional by putting them in an if: if(pick Person $p) { ... }

Let’s create a few people at startup, using the @start attribute on an event:

sample.moi
entity Artifact {
    prop date_created: number
    prop owner: Person
}
entity Person {
    prop birth_date: number
}

table NamePrefix { 'Sword', 'Spear', 'Boots', 
                   'Keychain', 'Grimoire'}
table NameSuffix { 'Destiny', 'Doom', 'Solace', 'Joy'}

@frequency(1, PerXYear, 15)
event artifact_created() {
    pick Person $p
    create Artifact $a: '{roll(NamePrefix)} of {roll(NameSuffix)}'
    // Time is an always present entity. #Time is the syntax to access a singleton
    set $a.date_created = #Time.year
    
    set $a.owner = $p
    record('Artifact {$a.name} has been created. {$p.name} owns it.')
}

table Names { 'Arwen', 'Brenna', 'Chandra', 'Dorian', 'Evangelique'}
table Surnames { 'Ambervale', 'Brackenridge', 'Corvane', 'Darkmoor', 'Emberlane'}

function create_person(){
    create Person $p: ('{roll(Names)} {roll(Surnames)}')
    record('{$p.name} is born')
}

@start
event populate_world() {
    // repeat a function call
    call(create_person, 10)
}

Display queries

We can add a display attribute to show the result of specific queries in the UI, here all items owned by someone:

Artifacts owned query

sample.moi
entity Artifact {
    prop date_created: number
    prop owner: Person
}
@display(Artifact, 'Artifacts owned', owner = $self)
entity Person { ... }

On created trigger

In addition to events, which happen randomly, you can also declare triggers, which react to changes. We can refactor the birth record into a trigger that runs when a Person is created, and also set a flag alive to true:

On Created Trigger

sample.moi
entity Person {
    prop birth_date: number
    prop alive: bool
}

function create_person(){
    create Person $p: ('{roll(Names)} {roll(Surnames)}')
    record('{$p.name} is born')
}

@start
event populate_world() {
    call(create_person, 10)
}

trigger birth {
    when_created Person
    set $new.birth_date = #Time.year
    set $new.alive = true
    record('{$new.name} is born')
}

On change trigger

Similarly, a trigger can react to a property change. A new event causes random people to die; a trigger reacts to alive = false and records the death.

On Changed Trigger

sample.moi
entity Person {
    prop birth_date: number
    prop alive: bool
}

function create_person(){
    create Person $p: ('{roll(Names)} {roll(Surnames)}')
}

@start
event populate_world() {
    call(create_person, 10)
}

trigger birth {
    when_created Person
    set $new.birth_date = #Time.year
    set $new.alive = true
    record('{$new.name} is born')
}
@frequency(1, PerXYear, 50)
event random_death() {
    pick Person $p: (alive)
    set $p.alive = false
}

trigger death {
    when Person and alive = false
    record('{$new.name} leaves this world')
}

A trigger to give the deceased items away

Building on the previous example, when a person dies, we can iterate over their items to give them to someone else.

Items being given on death of the owner

sample.moi
entity Person {
    prop birth_date: number
    prop alive: bool
}

function create_person(){
    create Person $p: ('{roll(Names)} {roll(Surnames)}')
}

@start
event populate_world() {
    call(create_person, 10)
}

trigger birth {
    when_created Person
    set $new.birth_date = #Time.year
    set $new.alive = true
    record('{$new.name} is born')
}
@frequency(1, PerXYear, 50)
event random_death() {
    pick Person $p: (alive)
    set $p.alive = false
}

trigger death {
    when Person and alive = false
    record('{$new.name} leaves this world')
    each Artifact $i: (owner = $new) {
        pick Person $other: $other != $new
        set $i.owner = $other
        record('The {$i.name} is given to {$other.name}')
    }
}

On storytelling and morality

I quickly encountered two issues: it takes a lot of rules to model a world worth narrating, and encoding morality in those rules is hard. Here is an example with weddings.

First definition

Initially, I started with something naive:

event wedding {
    pick Person $x
    pick Person $y
    set $x.partner = $y
    set $y.partner = $x
    record('{$x.name} and {$y.name} get married')
}

First problems

032 Evangelique Emberlane and Evangelique Emberlane get married

945 Brenna Brackenridge and Chandra Ambervale get married

945 Brenna Brackenridge and Dorian Ambervale get married

By failing to define marriage properly, I first encountered narcissism (people marrying themselves, $x == $y) and group marriage (partner != null: accepted in some cultures, but not the one I was modeling). We can add a couple of checks for that.

event wedding {
    pick Person $x: (partner = null)
    pick Person $y: ($y != $x and partner = null)
    set $x.partner = $y
    set $y.partner = $x
    record('{$x.name} and {$y.name} get married')
}

Not the time for necromancy

948 Chandra Ambervale dies in an accident

950 Brenna Brackenridge and Chandra Ambervale get married

Maybe we need people to be alive to get married. (Astute readers will have noticed the item inheritance trigger above has the same bug: nothing stops $other from being dead.)

event wedding {
    pick Person $x: (alive and partner = null)
    pick Person $y: ($y != $x and alive and partner = null)
    set $x.partner = $y
    set $y.partner = $x
    record('{$x.name} and {$y.name} get married')
}

Age check

948 Chandra Ambervale is born

958 Brenna Brackenridge and Chandra Ambervale get married

Yuck.

event wedding {
    pick Person $x: (alive and partner = null and age > Age.Child)
    pick Person $y: ($y != $x and alive and partner = null and age > Age.Child)
    set $x.partner = $y
    set $y.partner = $x
    record('{$x.name} and {$y.name} get married')
}

What’s a tree anyway?

745 Chandra Ambervale has a child, Brenna Brackenridge

775 Chandra Ambervale and Brenna Brackenridge get married

I first added a $y != $x.parent1 and $y != $x.parent2, then $x.parent1 != $y.parent1 ..., then wondered: what are the formal definitions of consanguinity, both legally and in terms of graph theory?

The common name for this metric is “degree of kinship”. In terms of graphs, if individuals are vertices and parent-child relationships are edges, it’s the length of the shortest path between two vertices going through a common ancestor. In civil law, the Roman method of counting kinship is used: find the closest common ancestor, count the generations from each individual up to that ancestor, and sum both counts.

I added a built-in function for that. With the Roman count, a parent is at degree 1, a sibling or grandparent at 2, an aunt or uncle at 3, a first cousin at 4, so related($x, $y, 6) rules out everyone up to second cousins:

related($a, $b, n)  ⇔  there is a common ancestor X with
                        depth(a → X) + depth(b → X) ≤ n
event wedding {
    pick Person $x: (alive and partner = null and age > Age.Child)
    pick Person $y: ($y != $x and alive and partner = null and age > Age.Child and not(related($x, $y, 6)))
    set $x.partner = $y
    set $y.partner = $x
    record('{$x.name} and {$y.name} get married')
}

So, yeah. There’s actually research on the topic, see Ethical formalism and Formal ethics.

Runtime

The core of the engine is the Database, which contains the various type definitions, entities, tables, events, triggers. It’s also the execution entry point. The RNG is seeded, to make runs deterministic.

Data and types

All definitions (types, properties, enums) have strongly typed IDs, like this one:

public readonly struct EntityTypeId : IEquatable<EntityTypeId>
{
    public static readonly EntityTypeId Null = new EntityTypeId(0);
    public readonly uint Id;
    public EntityTypeId(uint id) => Id = id;
    public bool IsValid => Id != 0;
}

They are all readonly structs wrapping uints. IDs start at 1: structs are easy to mis-initialize, so using default(T) as an invalid sentinel value makes debugging easier in my experience. When using IDs to index a 0-based C# array, I usually either add an empty value at index 0 or implement a custom indexer handling the -1/+1 offset.

Values are stored as either a reference to a string, an int or a float; everything else is type safety on top of it.

Value types are a pair of an enum and an optional index for references:

public readonly struct ValueType : IEquatable<ValueType>
{
    public readonly ValueBaseType BaseType;
    public readonly ushort Index;
}

public enum ValueBaseType : byte
{
    None,
    String,
    Ref, // Index used as EntityId
    Number,
    Float,
    Bool,
    Enum, // Index used as Enum value
    EnumType, // Index used as EnumDefinitionId
    EntityType, // Index used as EntityTypeId
    Percentage
}

Entities are typed, and their properties are eagerly allocated - any entity of type T with P properties has a value array of length P. I did try sparse properties, but the memory gain was not worth the compute cost.

Querying

I initially used SQLite - fantastic piece of software, but given the comparatively small queries I’m doing and the fact that all the data can fit in memory easily, I eventually replaced it with a custom implementation.

Most of my queries start with a type filter, so entities are indexed by type id. Boolean properties are indexed too, as alive was the bottleneck in my test world for a long time.

Events

The database also has a list of events (and triggers, see below), which both contain a list of instructions. They implement a PropertyValue Compute(ExecuteContext ctx) method - literals, expressions, function calls, if and match statements etc. This is a classic AST evaluator - I did consider converting it to some kind of bytecode, but as querying the database takes way longer than the base instruction execution, it went down the priority list.

Events can start in four ways: by being called explicitly, by being scheduled from another event, when resetting the database if they have a @start attribute, or when they are drawn randomly according to their @frequency(X, mode, Y) attribute, which comes in two modes.

EveryXYear: a fixed count, random timing

@frequency(3, EveryXYear, 10) means exactly 3 runs in every 10-year window.

PerXYear: an average rate

@frequency(1, PerXYear, 5) means on average 1 run every 5 years.

Records and changesets

The database keeps two separate logs: records and changesets. Records are the story, the prose you write:

event create_artifact() {
    record('Artifact {$a.name} has been created. {$p.name} owns it.')
}

Records have an optional weight (to filter important events), a year, a list of entity ids to follow references, optional tags for UI filtering and a source action. They’re also linked to changesets.

Changesets are the database’s ledger. Running an event opens a changeset, and every property set and entity create is added to it. Each change is stored as a delta. Changesets are what drive triggers.

Triggers

Triggers are run automatically in reaction to created or changed entities. They expose two variables: $old if it’s a change, $new in both cases. The when clause at the beginning is used to exit irrelevant triggers early.

trigger inherit {
    when Person and alive = false and $old.alive
    // ...
}

Scheduling

A past bottleneck in my test world was checking each year if people grew up. I use an Age enum where each value represents a 20-year slice of life. Initially it looked something like this:

enum Age { Child, Young, Adult, Old, Dead }
enum Job { Smith, Merchant, Monk }

entity Person {
    prop age: Age
    prop birthdate: number
    /* ...  */
}
@frequency(1, EveryXYear, 1)
event people_grow {
    each Person $p: (alive and age = Age.Child and (birthdate + 20) <= #Time.year) {
        set $p.age = Age.Young
        random_weighted 25 {
            5 => set $p.job = Job.Smith
            10 => set $p.job = Job.Merchant
            10 => set $p.job = Job.Monk
            // ...
        }
    }
    each Person $p: (alive and age = Age.Young and (birthdate + 40) <= #Time.year) {
        set $p.age = Age.Adult
    }
    // ...
}

It means that every year, we query every person at least once. This is one of the reasons I indexed boolean properties. It could be refactored to iterate over every person only once, or to run once every 5 or 10 years if aging doesn’t have to be exact.

I then realized this would work better event-driven instead of polled. A person’s age transitions are known the instant they’re born, so each one can be scheduled at its exact future year, instead of re-scanning the whole population every year. Inside a schedule block, $self is the entity it was scheduled on, and if $self.alive skips anyone who died before the transition came due:

trigger born {
    when_created Person
    set $new.birthdate = #Time.year
    schedule($new, $new.birthdate + 20) {
        if $self.alive {
            set $self.age = Age.Young
            random_weighted 25 {
                5 => set $self.job = Job.Smith
                10 => set $self.job = Job.Merchant
                10 => set $self.job = Job.Monk
            }
        }
    }
    // ...
}

Testing

On top of standard arrange-act-assert tests, the parser tests pretty-print the parsed code and check that the parse/print round trip is idempotent.

Engine tests use stories written inline or fixture files.

Tests

Profiling and performance

Once the world is warmed up, simulating a year doesn’t allocate anything besides the storage the world keeps growing. That storage grows in chunks: ChunkedList<T> (1024 items per chunk) and Slab<T> (~64 KB arrays), and strings can live in a Slab<char>. The BytesPerYear test tracks this: the median year of w.sg, my main test world (stories use either the .sg or .moi extension), allocates 0 bytes. 1000 years of it allocate 17.8 MB in total, with about 6 ms of GC pause and 21 MB still live afterwards.

To find what’s slow, the --profile flag prints a table after each simulation pass, with a row per event and per trigger: invocations, hit rate, self and inclusive time, and allocated KB.

Rule coverage counters are always on, profiler or not. The Rules page of the UI uses them to flag rules that never ran and rules that never completed, which usually means a pick that never matches.

Parsing and LSP

The parser lives in a separate DLL: the plan was to allow ahead-of-time parsing to some serializable format that could be run by the engine DLL, but I never got there.

I initially used ANTLR. Its grammar is great, but the build pipeline is not my favorite, especially with Java in the mix. I then migrated to Superpower, a really nice C# parser combinator library.

There is a separate parsing entry point for the LSP server, which keeps references to the source tokens in the parsed AST. That way, the LSP can locate error squiggles and symbol definitions correctly.

LSP1
LSP2

Hosts

The main UI is a Svelte SPA, which can either hit an ASP.NET Core API exposing an engine instance or a WASM build of the engine. It shows the record and changeset feeds, can filter by entity, show an entity’s properties and history, run DB queries, draw a family tree, and more.

The records feed, filterable by tag:

Records feed

Selecting an entity shows its properties next to its own history:

Entity properties and history

The family tree:

Family tree

World statistics, replayed from the changeset log:

World statistics

Rule coverage, to spot events and triggers that never fire:

Rule coverage

The WASM build also embeds a story editor for demo purposes:

Story editor

I should get around to writing a Unity host…

Conclusion

I hope this was a fun read. I don’t know if this will ever be useful to someone else, but I will definitely use it next time I run a TTRPG.

Next on the list: the Unity host, maybe a bytecode interpreter if queries ever stop being the bottleneck, and a lot more rules, because a world only gets interesting once enough of them start interacting. If you try the WASM build and write a story of your own, I’d love to see what comes out of it. Until then!