Files
storybook/docs/design.md
Sienna Meridian Satterwhite 4e4e5500b0 chore: prep v0.3 work
Signed-off-by: Sienna Meridian Satterwhite <sienna@r3t.io>
2026-02-14 13:29:33 +00:00

56 KiB

Storybook DSL — Design Plan

Status: Proposal Author: Sienna + Lonni Date: February 2026 Scope: Content authoring language, parser, resolver, tooling for Aspen agent simulation


1. What This Document Covers

This plan describes Storybook (.sb), a domain-specific language for authoring the content that drives Aspen's agent simulation: characters, life arcs, behavior trees, schedules, institutions, relationships, conditions, and the prose that gives them life. It covers the language design, the cross-referencing system, the implementation architecture, key design decisions and their rationale, and a phased build plan.

The primary user is Lonni, who is creative, intelligent, not a developer, and needs to build out an entire village's worth of characters, relationships, and narrative structure. The secondary user is Sienna, who will author behavior trees, schema definitions, and engine-facing content. The tertiary consumer is the Aspen game engine itself, which ingests compiled storybook data at runtime.


2. The Authoring Boundary

The most important design decision is what Lonni authors vs. what the engine generates at runtime. The RFC describes rich runtime state — bond strengths evolving through interaction, need levels decaying, schedules regenerating daily. Lonni doesn't author runtime state. She authors:

Lonni Authors Engine Generates
Character personas (personality, backstory, starting state) Actual need levels (decaying over time)
Relationship templates (what "Spousal" means structurally) Actual bond strengths (evolving through interaction)
Relationship instances (Martha and David are Spousal at sim start) Life arc state transitions during play
Life arc definitions (the state machines themselves) Schedule instances (generated daily from templates + context)
Schedule templates (daily patterns) Behavior tree evaluation results
Behavior tree definitions (decision logic) Substrate state
Institution definitions (governance, resources, roles) Entity positions, pathfinding
Condition definitions (effects, contagion profiles) Wake queue events
Locations, trait axes, capability sets CRDT sync traffic
Prose (backstory, descriptions, flavor text) Emergent interactions

The storybook is a world definition — initial conditions plus the rules that govern how things evolve. The engine is the physics that runs those rules. This separation is clean, and the grammar should reinforce it: storybook files describe what could be and what starts as, never what is happening right now.


3. Language Design

3.1 Design Principles

Narrative-first. The language should read like a description of a world, not like configuration. Lonni should be able to read a .sb file aloud and it makes sense.

One concept, one construct. Each simulation concept (character, life arc, schedule, etc.) gets dedicated syntax. No shoehorning behavior trees into the same structure as character bios.

References over duplication. Define once, reference everywhere. Override locally when needed.

Prose is a first-class citizen. Backstory, descriptions, and flavor text aren't comments or afterthoughts — they're part of the data, surfaced in-game, and should be comfortable to write.

Errors should be kind. Lonni is not a developer. Error messages must be precise, plain-language, and suggest fixes.

3.2 Top-Level Constructs

Every .sb file contains one or more top-level blocks. The keyword at the start of each block disambiguates unambiguously (LL(1) at the top level).

-- File-level imports
use <path>

-- Definitions (one or more per file)
enum <Name> { ... }
traits { ... }
capset <Name> { ... }
capability <Name> { ... }
need_template <Name> { ... }
character <Name> { ... }
life_arc <Name> { ... }
schedule <Name> { ... }
behavior <Name> { ... }
subtree <n> { ... }
institution <Name> { ... }
condition <Name> { ... }
relationship <Name> { ... }
location <Name> { ... }
species <Name> { ... }

3.3 Value Types

The language has a small, fixed type system. No user-defined types beyond enums.

Type Syntax Examples
Integer bare number 34, 500, 0
Float number with decimal 0.85, 1.0, 0.0
Boolean keyword true, false
String double-quoted "hello"
Identifier bare word martha, work, romantic
Qualified path dotted or :: separated institutions::Bakery::owner, bond::committed
Time literal HH:MM 5:00, 18:30, 23:59
Duration literal number + unit suffix 3d, 7d, 2h, 30m, 45s
Range value .. value 0.0 .. 1.0, 3d .. 7d
List [ comma-separated ] [english, spanish], [mon, tue, wed]
Prose block ---tag ... --- See §3.4

Duration units: d (days), h (hours), m (minutes), s (seconds). These are game-time, not real-time. The engine's GameTime RFC defines the mapping.

Ranges are inclusive on both ends. They're used for trait bounds, duration ranges, severity ranges — anywhere a value varies within limits.

3.4 Prose Blocks

Prose blocks use triple-dash delimiters with a tag name:

---backstory
Martha grew up in the valley, apprenticing at the old bakery from
age fourteen. When old Henrik retired, she took over — not because
she had grand ambitions, but because the town needed bread and she
knew how to make it.
---

Inside a prose block, everything is raw text. No escaping needed. The only rule is that a line containing exactly --- (and nothing else) ends the block. This means prose can contain dashes, em-dashes, markdown-like formatting, whatever — as long as no line is only three dashes.

Supported prose tags and their semantics are defined by the construct they appear in. A character block recognizes backstory, description, notes. An institution recognizes description, history. The validator checks that prose tags are valid for their context.

3.5 Comments

Line comments use --. There are no block comments. This is intentional — block comments interact poorly with prose blocks and add lexer complexity for minimal benefit.

-- This is a comment
character Martha {  -- inline comment
  age: 34
}

3.6 Enums

Enums define closed sets of valid values. They're used for bond types, coordination levels, autonomy levels, activity types — anything where the set of valid options is fixed.

enum BondType {
  romantic
  familial
  friendship
  professional
  caretaking
}

enum CoordinationLevel {
  none        -- strangers, no coordination
  ad_hoc      -- can propose one-off activities
  recurring   -- can establish patterns
  cohabiting  -- share schedule generation context
  dependent   -- one party has authority over other
}

Enum variants are ordered — their declaration order defines a total ordering usable in expressions (coordination >= cohabiting). This matters for coordination level thresholds and bond strength gates.

Enums can also carry associated data for documentation purposes:

enum ActivityType {
  work        -- sustained productive activity
  eat         -- consuming food or drink
  sleep       -- resting, dormant
  leisure     -- self-directed free time
  travel      -- moving between locations
  social      -- interaction-focused activity
  worship     -- religious or spiritual practice
  learn       -- education, skill development
}

3.7 The Schema Layer

The engine ships a set of .sb files that define the core schema — the enums, trait axes, capability types, and need definitions that the simulation expects. These live in a schema/ directory and are read-only from Lonni's perspective.

storybook/
├── schema/              -- shipped with engine, not edited by Lonni
│   ├── core.sb          -- BondType, CoordinationLevel, AutonomyLevel, etc.
│   ├── needs.sb         -- NeedType enum, base need_templates
│   ├── capabilities.sb  -- CapabilityType enum, base capsets
│   └── activities.sb    -- ActivityType enum
├── world/               -- Lonni's content
│   ├── characters/
│   ├── institutions/
│   └── ...
└── storybook.toml       -- project metadata, schema version

Lonni's content references schema types but can also define new enums for content-specific categorization. The validator ensures her content is compatible with the engine's expectations.

The storybook.toml is the one non-.sb file. It contains project metadata that doesn't belong in the DSL:

[storybook]
name = "Aspen Village"
schema_version = "0.1"
engine_compat = "0.1"

[directories]
schema = "schema"
world = "world"

TOML is fine here because it's a single flat config file Lonni rarely touches.


4. Cross-Referencing System

4.1 Namespacing

Every top-level definition has a qualified name derived from its file path and block name. The directory structure under world/ defines the namespace:

world/characters/martha.sb  →  defines  character Martha
                               namespace: characters::Martha

world/institutions/bakery.sb → defines  institution Bakery
                               namespace: institutions::Bakery

world/shared/traits.sb       → defines  traits PersonalityTraits
                               namespace: shared::PersonalityTraits

The namespace is <directory_path_from_world_root>::<BlockName>. Files can define multiple blocks, all sharing the directory prefix.

4.2 use Statements

use statements bring names into scope at file level. They go at the top of the file, before any definitions.

-- Bring a single name into scope
use shared::PersonalityTraits

-- Bring multiple names from the same directory
use conditions::{Flu, Cold, Heartbreak}

-- Wildcard: bring everything from a directory
use shared::*

-- Schema references (implicit prefix)
use schema::core::BondType

Within the same directory, bare names resolve without use. If martha.sb and david.sb are both in characters/, Martha can reference David by bare name. Cross-directory references require use or a fully qualified path.

Schema types are always in scope without explicit use. BondType, CoordinationLevel, ActivityType, etc. are globally visible. This keeps Lonni from needing boilerplate imports for core vocabulary.

4.3 Qualified Paths

Anywhere a name is expected, a qualified path works:

roles: [institutions::Bakery::owner, households::Miller::adult]
arc: archetypes::PersonArc at Adult::Partnered
susceptible: [conditions::Flu, conditions::Cold]

The :: separator drills into definitions. institutions::Bakery::owner means "the owner role defined inside the Bakery institution in the institutions/ directory." The resolver validates that each segment of the path exists and is the right kind of thing.

Relationships are the trickiest cross-reference because they're bidirectional, involve two files, and feelings aren't always mutual.

Basic Syntax

-- In characters/martha.sb
character Martha {
  link David via Spousal {
    coordination: cohabiting
    started: year 3
    bond: 0.82         -- symmetric: same value for both parties
  }
}

This declares that Martha and David have a Spousal relationship. The resolver handles bidirectionality:

  1. When processing Martha's file, it registers Martha <-> David via Spousal
  2. When processing David's file, it checks whether David also declares a link to Martha
  3. If David doesn't declare it: the relationship is automatically bidirectional. David inherits it.
  4. If David does declare it: the resolver checks compatibility. Shared fields must agree; per-direction fields are merged (see below).

The recommendation is: declare each relationship once, in whichever file feels natural. For Martha and David, put it in Martha's file (or David's — just pick one). The resolver makes it visible from both sides.

Asymmetric Values

Feelings aren't always mutual. Martha might love David differently than David loves Martha. The self/other blocks let each party have distinct values:

character Martha {
  link David via Spousal {
    -- Shared (inherently bilateral, same for both parties)
    coordination: cohabiting
    started: year 3

    -- Per-direction (Martha's feelings toward David)
    self {
      bond: 0.82
      warmth: 0.9
      tension: 0.1
    }

    -- Per-direction (David's feelings toward Martha)
    other {
      bond: 0.65
      warmth: 0.7
      tension: 0.25
    }
  }
}

self always means "the character this link block appears inside" — Martha in this case. other is the other party — David. This is unambiguous regardless of which file the link lives in.

Mixing symmetric and asymmetric: Fields at the top level are shared. The moment a field appears inside self or other, it becomes per-direction. You can mix freely — coordination and started are shared (they're either cohabiting or they're not), while bond, warmth, and tension are per-direction because feelings are asymmetric.

Shorthand: If you don't use self/other, all values are symmetric — the same for both parties. This is the common case for simple relationships:

link Elena via Friendship {
  bond: 0.5
  coordination: recurring
}

Relationship Templates and Asymmetry

The relationship template declares which fields support asymmetry:

relationship Spousal {
  bond_type: romantic
  min_coordination: cohabiting

  -- Fields that can differ per-party
  asymmetric: [bond, warmth, tension]

  -- Fields that are always shared (coordination, commitments,
  -- started are inherently bilateral and cannot be asymmetric)

  commitments {
    dinner_together { daily at 18:00, location: home, activity: eat }
    sleep_together  { daily at bedtime, location: home, activity: sleep }
  }

  levels {
    acquaintance (0.0 .. 0.2) { enables: [greet] }
    friendly     (0.2 .. 0.5) { enables: [greet, chat, share_meal] }
    close        (0.5 .. 0.7) { enables: [greet, chat, share_meal, visit, confide] }
    intimate     (0.7 .. 1.0) { enables: [all, shared_schedule, cohabitation] }
  }
}

The validator uses the asymmetric declaration to catch mistakes — if Lonni puts coordination inside a self block, she gets a clear error: "coordination is a shared field in Spousal and cannot differ between parties." If she puts bond at the top level, that's fine — it just means both parties share the same bond strength.

Asymmetric Role Relationships

Some relationships are asymmetric by structure, not just by values — parent/child, employer/employee, mentor/apprentice. These use the as clause to assign roles:

character Martha {
  link Tommy via Parental as parent {
    coordination: dependent
    started: year 0

    self {              -- Martha (parent) toward Tommy
      bond: 0.95
      warmth: 0.95
    }
    other {             -- Tommy (child) toward Martha
      bond: 0.9
      warmth: 0.85
    }
  }
}

The as parent clause assigns Martha's role. Tommy gets the complementary role (child, defined in the Parental relationship template). The template defines which roles exist and what each role enables:

relationship Parental {
  bond_type: familial
  roles: [parent, child]
  asymmetric: [bond, warmth, tension]

  role parent {
    authority_over: child
    capabilities: [set_schedule, set_boundaries, provide_care]
  }

  role child {
    autonomy: external       -- schedule set by parent
    capabilities: [receive_care, seek_comfort]
  }
}

This handles parent-child, employer-employee, caretaker-dependent, mentor-apprentice — any relationship where the two parties have fundamentally different roles and affordances.

4.5 override — Template Customization

When referencing a template (schedule, needs, relationship), the override keyword selectively patches fields:

character Martha {
  needs: HumanNeeds {
    override sleep { decay: 0.9 }    -- only changes decay rate
    override social { urgent: 0.6 }  -- Martha's more socially needy
  }

  schedule: BakerSchedule {
    override block lunch { time: 12:30 - 13:00 }
    remove block evening_walk
    append block meditation { 5:00 - 5:15  activity: meditate  at: home }
  }
}

Merge semantics:

Operation Meaning Syntax
override <target> { fields } Recursive structural merge. Only specified fields are replaced; everything else inherits from the template. override sleep { decay: 0.9 }
remove <target> Delete an inherited item entirely. remove block evening_walk
append <kind> <name> { fields } Add a new item not present in the template. append block meditation { ... }

For nested structures, override is recursive. override block lunch { time: 12:30 - 13:00 } patches only the time field of the lunch block — activity, at, priority all keep their template values.

Lists are replaced wholesale by default. If a template defines capabilities: [work, trade, parent] and an override says capabilities: [work, trade], the result is [work, trade]. For additive modification, use explicit operations:

override capabilities {
  add mentor
  remove parent
}

4.6 include — Composition

include is for composing capability sets, need templates, and other aggregates:

capset FullAdult {
  include BasicCapabilities    -- pull in everything from BasicCapabilities
  work, trade, parent, travel  -- add more
}

This is purely additive — include pulls in all items from the referenced set. It's distinct from use (which affects name visibility) and override (which patches inherited values). include is "copy these items into this definition."

4.7 Resolution Pipeline

Cross-referencing happens in a multi-pass pipeline after parsing:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│   1. Parse   │────▶│  2. Register │────▶│  3. Resolve  │────▶│  4. Merge   │────▶│  5. Validate │
│  all .sb     │     │   names      │     │  references  │     │  overrides   │     │  semantics   │
│  files       │     │              │     │              │     │              │     │              │
└─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘     └─────────────┘
     │                    │                    │                    │                    │
  LALRPOP +           Walk all            Walk all             Apply override       Type-check,
  logos produce       top-level           use stmts,           /remove/append       range-check,
  per-file ASTs       defs, build         qualified            to produce           constraint
                      name table          paths, link          fully-resolved       verification,
                      with kinds          stmts. Check         entities             cross-entity
                                          existence +                               consistency
                                          kind matching.
                                          Detect cycles.

Pass 1 — Parse. Each .sb file is independently parsed by LALRPOP + logos into an AST. Parse errors are reported with file, line, column. No cross-file awareness needed.

Pass 2 — Register. Walk all ASTs, collect every top-level definition into a name table: { qualified_path → (kind, file, AST node) }. Detect duplicate definitions (same name in same namespace). This pass is O(n) in total definitions.

Pass 3 — Resolve. Walk every reference (use statements, qualified paths, link targets, template references) and resolve them against the name table. Check that the referenced thing exists and is the right kind (you can't use a condition where a schedule is expected). Detect unresolved references with fuzzy-match suggestions: "Martha references conditions::Flue but no condition named Flue exists. Did you mean conditions::Flu?"

Also in this pass: detect circular includes (capset A includes capset B includes capset A). Circular links are fine (Martha links to David, David links to Martha) — they're just bidirectional relationships.

Pass 4 — Merge. For every entity with template references + overrides, produce a fully resolved version. Martha's needs: HumanNeeds { override sleep { decay: 0.9 } } becomes a concrete needs block with all values filled in. This pass produces the "flat" representation that the engine can consume directly.

Pass 5 — Validate. Semantic checks:

  • Trait values are within declared ranges
  • Schedule times don't have impossible overlaps (two mandatory blocks at the same time)
  • Life arc transitions reference valid states
  • Behavior tree nodes reference valid actions/conditions known to the engine
  • Relationship bond values are in 0.0 .. 1.0
  • All roles referenced by characters exist in their institutions
  • Shared commitments reference entities that exist
  • Condition severity ranges are valid
  • Required fields are present (every character needs at minimum an age and species)

Validation errors are reported with full context: which file, which block, which field, what's wrong, and what would fix it.


5. The Expression Language

Life arc transitions, behavior tree conditions, and constraint rules need a small expression language.

5.1 Scope

This is deliberately minimal. It's not a programming language. It handles:

  • Comparisons: age >= 13, bond >= 0.7, coordination >= cohabiting
  • Logical combinators: and, or, not
  • Field access: entity.age, bakery.stock, batch.phase
  • Quantifiers: relationship.any(bond >= romantic_interest), children.all(age >= 18)
  • Event references: event::child_born, event::death, event::separation
  • State checks: is keyword for enum comparison (batch.phase is rising, autonomy is full)

5.2 Grammar

Expr     = OrExpr
OrExpr   = AndExpr ("or" AndExpr)*
AndExpr  = NotExpr ("and" NotExpr)*
NotExpr  = "not" Atom | Atom
Atom     = Comparison | EventRef | Quantifier | "(" Expr ")"
Comparison = FieldAccess CompOp Value
CompOp   = ">=" | "<=" | ">" | "<" | "==" | "!=" | "is"
FieldAccess = Ident ("." Ident)*
EventRef = "event" "::" Ident
Quantifier = FieldAccess "." ("any" | "all" | "none") "(" Expr ")"
Value    = Number | Float | Ident | QualifiedPath | Duration | Time

5.3 Evaluation

Expressions are not evaluated at parse time. They're stored in the AST as expression trees and compiled into Rust predicates during engine loading. The validator checks that field paths reference real fields and that types are compatible (you can't compare an integer to an enum without is).

For behavior trees, the ! condition nodes contain expressions:

! bakery.customer_queue > 0
! batch.phase is mixing
! need.any is urgent

For life arc transitions:

Child -> Adolescent  when age >= 13
Partnered -> Parent  when event::child_born
Parent -> Partnered  when children.all(age >= 18)

The when clause is an expression. Compound conditions use and/or:

Single -> Courting  when relationship.any(type is romantic and bond >= 0.3)

5.4 What the Expression Language Is NOT

It cannot:

  • Assign values
  • Call functions
  • Loop or iterate
  • Define variables
  • Do arithmetic (no a + b, no stock * 0.5)

If something requires computation, it belongs in the engine, not in content. The expression language is purely for predicates — boolean questions about world state.

One exception worth considering: simple arithmetic in need modifiers and capability modifiers, like rate: *1.5 or threshold: -0.2. These aren't general expressions — they're specific modifier syntax within condition effect blocks. They use prefix operators (* for multiply, +/- for add/subtract) applied to a single value. This is limited enough to stay in the grammar without opening the door to general computation.


6. Behavior Tree Syntax

Behavior trees deserve special attention because they're the most structurally complex construct and they bridge content authoring with engine code.

6.1 Node Types

Sigil Name Semantics
? Selector Try children in order; succeed on first success
> Sequence Run children in order; fail on first failure
! Condition Evaluate an expression; succeed if true
@ Action Execute a named engine action
~ Decorator Modify child behavior (invert, repeat, cooldown)

6.2 Syntax

behavior WorkAtBakery {
  applies_to: activity(work) at institution(bakery)

  tree {
    ? root {
      > handle_urgent {
        ! need.any is urgent
        @ handle_urgent_need
      }
      > serve_customers {
        ! bakery.customer_queue > 0
        @ move_to(counter)
        @ serve_next_customer
      }
      > continue_batch {
        ! bakery.active_batches > 0
        ? batch_phase {
          > mixing  { ! batch.phase is mixing;  @ mix_dough }
          > rising  { ! batch.phase is rising;   @ wait_for_timer }
          > baking  { ! batch.phase is baking;   @ tend_oven }
          > done    { ! batch.phase is done;     @ unload_and_stock }
        }
      }
      ? idle {
        @ clean
        @ organize
        @ idle_wait
      }
    }
  }
}

Semicolons separate children on a single line. Newlines also separate children. This gives flexibility — simple sequences can be one-liners, complex trees can be multi-line.

6.3 Actions and the Engine Interface

Action names (handle_urgent_need, mix_dough, serve_next_customer) reference engine-defined behaviors. The engine publishes an action registry (another .sb file in schema/) that lists valid action names with their parameters:

-- schema/actions.sb
action move_to {
  param target: Location
  ---description
  Navigate the entity to the specified location using pathfinding.
  ---
}

action serve_next_customer {
  requires: capability(work) at institution with customer_queue
  ---description
  Dequeue the next customer and perform a service interaction.
  ---
}

action wait_for_timer {
  ---description
  Do nothing until the current timed process completes.
  ---
}

The validator checks that every @ action in a behavior tree matches a registered action. Unknown actions are errors with suggestions.

6.4 Who Authors Behavior Trees

Realistically, behavior trees are the most engine-facing construct. Sienna will author most of them. But the grammar makes them accessible enough that Lonni could modify existing trees — tweaking priorities, adding idle behaviors, adjusting conditions. The egui tool will represent BTs as a visual node graph with drag-and-drop editing, so she never needs to touch the text syntax directly unless she wants to.

6.5 Subtrees

Common behavior patterns appear across many trees — "handle urgent need" is in virtually every work behavior, "flee threat" is in every wildlife mode. Rather than duplicating these, the subtree construct defines a reusable fragment:

subtree HandleUrgentNeed {
  > handle_urgent {
    ! need.any is urgent
    @ handle_urgent_need
  }
}

subtree FleeThreat {
  > flee {
    ! threat.detected
    @ flee_from(threat.source)
    ! distance_to(threat.source) >= safety_range
  }
}

Subtrees are referenced inside behavior trees with use_subtree:

behavior WorkAtBakery {
  applies_to: activity(work) at institution(bakery)

  tree {
    ? root {
      use_subtree HandleUrgentNeed
      > serve_customers {
        ! bakery.customer_queue > 0
        @ move_to(counter)
        @ serve_next_customer
      }
      > continue_batch { ... }
      ? idle { ... }
    }
  }
}

behavior WorkAtClinic {
  applies_to: activity(work) at institution(clinic)

  tree {
    ? root {
      use_subtree HandleUrgentNeed     -- same subtree, different context
      > treat_patients { ... }
      ? idle { ... }
    }
  }
}

use_subtree inlines the referenced subtree's children at that position in the tree. It's resolved during the merge pass (pass 4), not at parse time — the parser just records it as a reference. The validator checks that the referenced subtree exists and that inlining it doesn't create cycles.

Subtrees follow the same cross-referencing rules as everything else: they have qualified names, can be imported with use, and can live in any .sb file. A natural home is alongside the behavior trees that use them:

storybook/
└── world/
    └── behaviors/
        ├── common.sb       -- subtree HandleUrgentNeed, subtree FleeThreat, etc.
        ├── work.sb         -- behavior WorkAtBakery, behavior WorkAtClinic
        └── wildlife.sb     -- behavior DeerBehavior, behavior BirdBehavior

Subtrees can reference other subtrees (use_subtree inside a subtree), enabling composition. The resolver detects circular references.


7. Species and Entity Archetypes

The RFC describes very different entity types — people, cats, deer, cars, buildings. Rather than having the grammar handle each specially, we use a species construct that defines what components an entity type has:

species Human {
  components: [lifecycle, physical_presence, needs, instincts,
               roles, autonomy, relationships, membership]
  life_arc: PersonArc
  needs: HumanNeeds

  ---description
  Sapient beings with full agency, social bonds, and institutional
  participation. The primary inhabitants of the village.
  ---
}

species Deer {
  components: [lifecycle, physical_presence, needs, instincts]
  behavior_mode: WildlifeModes    -- simplified BT, not full behavior trees
  herd: true

  ---description
  Wild ungulates. No social structure beyond herding. Flight-driven
  threat response. Grazes on foliage, interacts with substrate.
  ---
}

species Building {
  components: [lifecycle, physical_presence, operable, stateful]
  -- No agency, no needs, no behavior trees

  ---description
  Static structures that provide shelter, services, and institutional
  anchoring. Age and decay over time via lifecycle.
  ---
}

Then characters reference their species:

character Martha {
  species: Human
  age: 34
  ...
}

The validator uses the species definition to check that the character only includes components appropriate for their species. A Deer character can't have roles or relationships. A Building can't have personality.


8. Locations

Locations are referenced everywhere — schedules, behavior trees, relationship commitments. They need their own construct:

location TownSquare {
  position: [50, 32]      -- tile coordinates
  size: [3, 3]            -- in 10m tiles
  tags: [public, outdoor, gathering]

  activities: [social, eat, leisure, worship]
  capacity: 50

  ---description
  The heart of the village. A cobblestone square with a central fountain,
  benches under old oaks, and a small stage for festivals. Market stalls
  line the eastern edge on Saturdays.
  ---
}

location Bakery {
  position: [48, 30]
  size: [2, 1]
  tags: [indoor, workplace, commercial]
  institution: institutions::Bakery    -- links to the institution definition

  activities: [work, eat]
  capacity: 8

  substations {
    counter  { position: [0, 0], activities: [work] }
    oven     { position: [1, 0], activities: [work], capabilities: [heat] }
    seating  { position: [0, 1], activities: [eat, social], capacity: 4 }
  }
}

Locations bridge the spatial world (tile positions, sizes) with the simulation world (what activities happen here, who works here, what institution owns this space). The substations block defines finer-grained positions within a location, referenced by behavior trees (@ move_to(counter)).


9. Implementation Architecture

9.1 Crate Structure

storybook/                          -- separate repo (e.g. github.com/aspen-game/storybook)
├── Cargo.toml                      -- workspace root
├── storybook/                      -- core crate: parser, resolver, API, CLI
│   ├── src/
│   │   ├── lib.rs
│   │   ├── syntax/
│   │   │   ├── mod.rs
│   │   │   ├── lexer.rs            -- logos lexer with prose mode
│   │   │   ├── ast.rs              -- AST node types
│   │   │   └── parser.lalrpop      -- LALRPOP grammar
│   │   ├── resolve/
│   │   │   ├── mod.rs
│   │   │   ├── names.rs            -- name table, qualified path resolution
│   │   │   ├── merge.rs            -- override/remove/append merging
│   │   │   └── validate.rs         -- semantic validation
│   │   ├── diagnostics.rs          -- error formatting with miette/ariadne
│   │   ├── project.rs              -- Project::load(path), filesystem handling
│   │   ├── query.rs                -- query API (find characters, list relationships, etc.)
│   │   └── types.rs                -- resolved types (what consumers see)
│   ├── src/bin/
│   │   └── sb.rs                   -- CLI binary: sb validate, sb query, sb watch
│   └── Cargo.toml
│
├── storybook-editor/               -- editor crate: egui-based visual editor
│   └── ...                         -- depends on storybook + eframe, egui, octocrab, git2
│
└── schema/                         -- default schema .sb files shipped with the crate
    ├── core.sb
    ├── needs.sb
    ├── capabilities.sb
    └── activities.sb

This is its own repository, separate from the Aspen game engine. The workspace contains two crates: the core storybook library+CLI and the storybook-editor GUI application.

The syntax and resolve modules are pub for internal organization but the public API surface is Project::load(), the query interface, and the resolved types. The CLI is a binary target within the core crate.

The editor stays in a separate workspace crate because it pulls in heavy GUI dependencies (eframe, egui_node_graph, octocrab, git2) that don't belong in the engine's dependency tree.

The Aspen engine depends on the storybook crate via a git dependency or path dependency (during development):

# In aspen's Cargo.toml
[dependencies]
storybook = { git = "https://github.com/aspen-game/storybook", branch = "main" }

If compile times become a problem later, the module boundaries make splitting the core crate into sub-crates a mechanical refactor — but at this scale, one library crate is simpler to maintain and faster to iterate on.

9.2 Lexer Design (logos)

The lexer needs two modes because of prose blocks:

Normal mode: Standard tokenization — keywords, identifiers, numbers, operators, punctuation.

Prose mode: Activated when the lexer encounters --- followed by an identifier. Captures everything as raw text until a line containing only ---. Emits a single ProseBlock(tag, content) token.

#[derive(Logos, Debug, PartialEq)]
#[logos(skip r"[ \t]+")]            // skip whitespace (not newlines)
#[logos(skip r"--[^\n]*")]          // skip line comments
enum Token {
    // Keywords
    #[token("use")]       Use,
    #[token("character")]  Character,
    #[token("life_arc")]   LifeArc,
    #[token("schedule")]   Schedule,
    #[token("behavior")]   Behavior,
    #[token("subtree")]    Subtree,
    #[token("use_subtree")] UseSubtree,
    #[token("institution")] Institution,
    #[token("condition")]  Condition,
    #[token("relationship")] Relationship,
    #[token("location")]   Location,
    #[token("species")]    Species,
    #[token("enum")]       Enum,
    #[token("traits")]     Traits,
    #[token("capset")]     Capset,
    #[token("capability")] Capability,
    #[token("need_template")] NeedTemplate,
    #[token("link")]       Link,
    #[token("via")]        Via,
    #[token("as")]         As,
    #[token("self")]       Self_,
    #[token("other")]      Other,
    #[token("asymmetric")] Asymmetric,
    #[token("override")]   Override,
    #[token("remove")]     Remove,
    #[token("append")]     Append,
    #[token("include")]    Include,
    #[token("when")]       When,
    #[token("at")]         At,
    #[token("on")]         On,
    #[token("to")]         To,
    #[token("from")]       From,
    #[token("and")]        And,
    #[token("or")]         Or,
    #[token("not")]        Not,
    #[token("is")]         Is,
    #[token("true")]       True,
    #[token("false")]      False,
    #[token("event")]      Event,
    #[token("tree")]       Tree,
    #[token("block")]      Block,
    #[token("state")]      State,
    #[token("substates")]  Substates,
    #[token("transitions")] Transitions,

    // Sigils (behavior tree)
    #[token("?")]  Selector,
    #[token("!")]  Condition,
    #[token("@")]  Action,
    #[token(">")]  Sequence,
    #[token("~")]  Decorator,

    // Punctuation
    #[token("{")]  LBrace,
    #[token("}")]  RBrace,
    #[token("[")]  LBracket,
    #[token("]")]  RBracket,
    #[token("(")]  LParen,
    #[token(")")]  RParen,
    #[token(":")]  Colon,
    #[token("::")]  PathSep,
    #[token(",")]  Comma,
    #[token(";")]  Semi,
    #[token("..")]  DotDot,
    #[token(".")]  Dot,
    #[token("->")]  Arrow,
    #[token("<->")]  BiArrow,
    #[token("*")]  Star,
    #[token(">=")]  Gte,
    #[token("<=")]  Lte,
    #[token("==")]  Eq,
    #[token("!=")]  Neq,
    // Note: > and < overlap with Sequence sigil and other uses.
    // The parser disambiguates by context (> at start of line in a tree
    // block = sequence; > between expressions = comparison).

    #[token("\n")]  Newline,

    // Literals
    #[regex(r"[0-9]+:[0-9]{2}", |lex| lex.slice().to_string())]
    TimeLiteral(String),

    #[regex(r"[0-9]+[dhms]", |lex| lex.slice().to_string())]
    DurationLiteral(String),

    #[regex(r"[0-9]+\.[0-9]+", |lex| lex.slice().parse::<f64>().unwrap())]
    Float(f64),

    #[regex(r"[0-9]+", |lex| lex.slice().parse::<i64>().unwrap())]
    Integer(i64),

    #[regex(r#""[^"]*""#, |lex| lex.slice()[1..lex.slice().len()-1].to_string())]
    StringLiteral(String),

    #[regex(r"[a-zA-Z_][a-zA-Z0-9_]*", |lex| lex.slice().to_string())]
    Ident(String),

    // Prose blocks — handled by a wrapper around the logos lexer.
    // When the wrapper sees `---` followed by an Ident on the same logical
    // line, it switches to raw capture mode.
    ProseBlock { tag: String, content: String },
}

The prose block handling can't be done purely in logos regex — it needs stateful scanning. The approach: write a thin wrapper StorybookLexer that drives logos for normal tokens but intercepts --- sequences and manually scans ahead for prose blocks. LALRPOP's external lexer interface supports this cleanly.

9.3 LALRPOP Considerations

LALRPOP generates an LALR(1) parser. A few areas need care:

The > ambiguity. In behavior tree blocks, > is the sequence sigil. In expressions, > is a comparison operator. These contexts are syntactically separated — > as a sigil only appears at the start of a behavior tree child node, which is inside a tree { ... } block. The grammar can handle this by having separate productions for tree nodes vs. expressions.

Newlines as separators. The grammar uses both newlines and semicolons as statement separators within blocks. LALRPOP handles this if newlines are explicit tokens (not skipped). The grammar needs Sep productions that accept either.

Error recovery. LALRPOP supports error recovery via ! in grammar rules, allowing the parser to skip to the next statement on error and continue parsing. This is important for the editor — Lonni wants to see all errors at once, not just the first one.

9.4 Diagnostics

Error reporting uses the ariadne or miette crate for rich, colorful error messages:

error[E0301]: unresolved reference
  ┌─ world/characters/martha.sb:14:17
  │
14 │   susceptible: [Flue, Cold]
  │                 ^^^^ no condition named `Flue` exists
  │
  = help: did you mean `Flu`? (defined in world/conditions/illness.sb)
error[E0502]: type mismatch in override
  ┌─ world/characters/martha.sb:22:27
  │
22 │     override sleep { decay: "fast" }
  │                            ^^^^^^ expected float, found string
  │
  = note: `decay` is defined as a float in `HumanNeeds`
          (world/shared/needs.sb:4)
warning[W0101]: relationship declared in both files
  ┌─ world/characters/david.sb:8:3
  │
 8 │   link Martha via Spousal { bond: 0.82 }
  │   ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  │
  = note: this relationship is already declared in
          world/characters/martha.sb:15
  = help: declare a relationship in one file only; it's automatically
          visible from both sides. use self/other blocks for
          asymmetric values.

The diagnostic system is a module within the storybook crate so it can be reused by both the CLI and the editor.


10. The Editor (storybook-editor)

10.1 Architecture

A standalone eframe application (not inside Bevy). Depends on the storybook crate for parsing, resolution, and validation. Uses the filesystem as the source of truth — every save writes .sb files and triggers a re-resolve.

┌────────────────────────────────────────────────┐
│                storybook-editor                 │
│                                                │
│  ┌──────────┐  ┌──────────┐  ┌──────────────┐ │
│  │ Character│  │  Life Arc│  │  Relationship │ │
│  │  Editor  │  │  Editor  │  │    Graph      │ │
│  │  (forms) │  │  (nodes) │  │   (nodes)     │ │
│  └────┬─────┘  └────┬─────┘  └──────┬───────┘ │
│       │              │               │         │
│  ┌────┴──────────────┴───────────────┴───────┐ │
│  │           storybook crate                  │ │
│  │  parse ──▶ resolve ──▶ validate ──▶ query │ │
│  └────────────────────┬──────────────────────┘ │
│                       │                        │
│  ┌────────────────────┴──────────────────────┐ │
│  │         filesystem (.sb files)             │ │
│  │         + transparent git                  │ │
│  └───────────────────────────────────────────┘ │
└────────────────────────────────────────────────┘

10.2 Panels

Panel What It Shows Primary Interaction
Character Editor Form fields for structured data, embedded text area for prose, portrait slot Fill in fields, write backstory, link relationships
Life Arc Editor Node graph — states as nodes, transitions as edges with when conditions Drag states, draw transition arrows, edit conditions
Schedule Builder Timeline/calendar view with draggable time blocks Drag blocks to resize/reposition, color by activity type
Behavior Tree Editor Node graph — tree hierarchy with condition/action nodes Drag to rearrange, click to edit conditions/actions
Relationship Map Force-directed graph — characters as nodes, relationships as edges Click edges to see/edit details, drag nodes to arrange
Location Browser Grid map showing tile positions + detail panel Click locations, edit properties, link to institutions
Issues & PRs GitHub issues and pull requests, linked to storybook entities Create, comment, close issues; review PRs; cross-link to characters/systems
Diagnostics Panel Live error/warning list with click-to-navigate Click an error to jump to the relevant panel + field

10.3 Git Integration

The editor manages git transparently:

  • Every save creates a commit with an auto-generated message: "Update Martha: modified backstory and personality traits"
  • Branch switching is exposed as "versions" in the UI — Lonni can create a "what if" branch, make changes, and merge back
  • Undo is just git revert under the hood
  • History shows a timeline of changes with diffs rendered as "what changed" in plain language, not code diffs

The git operations use git2 (libgit2 bindings for Rust). No shell-outs, no git CLI dependency.

10.4 GitHub Integration

The editor integrates with GitHub so Lonni never has to leave the app for project management:

Feature What It Does
Issues Browse, create, edit, close issues. Tag issues to specific characters, locations, or systems. Filter by label, assignee, milestone.
Pull Requests View open PRs, see diff summaries in plain language ("Martha's personality changed, new location added"), approve/comment.
Comments Comment on issues and PRs inline. Mention characters or systems with @-style autocomplete linked to storybook entities.
Labels & Milestones Manage labels (e.g., character, institution, bug, story-beat) and milestones from within the editor.

The GitHub API integration uses octocrab (async GitHub client for Rust). Authentication is via a personal access token stored in the system keychain — configured once, never asked again.

The UI surfaces this as an Issues panel alongside the content panels. Issues can be cross-linked to storybook entities: clicking "Related: Martha" in an issue jumps to the character editor. Creating a character or relationship can auto-suggest creating a tracking issue.

Push/pull operations are exposed as "Sync" — a single button that pushes local commits and pulls remote changes. Merge conflicts (rare, since Lonni is typically the sole content author) are surfaced with plain-language descriptions and resolved in the editor.

10.5 Self-Updating

The editor binary is distributed as a single executable. Self-update checks a release endpoint on startup (configurable, could be a GitHub release or your own server) and downloads new versions in the background. The update applies on next launch.


11. Engine Integration

11.1 Loading

The game engine depends on the storybook crate. At startup (or when entering a save), it:

  1. Loads the storybook directory
  2. Parses, resolves, validates
  3. Converts resolved types into Bevy ECS components and resources
  4. Spawns entities with their initial state

The conversion from storybook::types::Character to Bevy components is a straightforward mapping — personality traits become a Personality component, needs become a Needs component, etc. Life arc definitions become state machine resources. Behavior tree definitions become BehaviorTree components. Schedules become ScheduleTemplate resources.

11.2 Hot Reloading (Development)

During development, the engine watches the storybook directory for changes (via notify crate). On change:

  1. Re-parse only the changed files
  2. Re-run resolution (full, since cross-references may have changed — this should be fast enough for hundreds of files)
  3. Diff the resolved state against the current ECS state
  4. Apply delta updates to live entities

This lets Sienna edit a character's personality in a text editor and see the behavior change in real-time without restarting the game.

11.3 Compiled Format

For release builds, the storybook is compiled into a binary format (MessagePack, bincode, or similar) that skips parsing and resolution entirely. The build pipeline is: .sb files → storybook crate → resolve → serialize → .aspen binary. The game ships the .aspen file, not the raw .sb files.


12. Open Design Questions

12.1 Versioning and Migration

The schema uses major.minor versioning with an additive-only evolution policy:

  • Minor versions (0.1 → 0.2) add new constructs, fields, enum variants, or optional features. Old files remain valid — new fields have defaults, new constructs are simply unused.
  • Major versions (0.x → 1.0) are reserved for if a breaking change ever becomes unavoidable. We avoid this as long as possible.

The schema version is tracked in storybook.toml:

[storybook]
schema_version = "0.1"

The validator checks compatibility: a storybook authored against schema 0.1 is valid against 0.2 (new features ignored). A storybook authored against 0.2 may use features not present in 0.1 (validator warns if downgrade is attempted).

If a breaking change becomes necessary in the future, it will ship with a migration guide and a sb migrate CLI command. But this is deferred — the schema is small enough and new enough that additive evolution should cover the foreseeable horizon.

12.2 Localization of Prose

Character backstories, descriptions, and institution flavor text — should they be localizable? If Aspen ships in multiple languages, the prose blocks need a localization story.

Option: Prose blocks become keys into a localization table. The .sb file contains the default language; translations live in companion files (martha.sb.pt, martha.sb.es). The editor shows the default language and flags untranslated content.

Recommendation: Defer. Build the grammar without localization support. Add it as a file-level concern later (companion files, not grammar changes).

12.3 Procedural Generation Integration

The RFC envisions up to 500 entities. Lonni isn't going to hand-author 500 characters. Some will be procedurally generated from templates. The grammar should support:

template VillagerTemplate {
  species: Human

  personality {
    openness: 0.2 .. 0.8           -- random within range
    conscientiousness: 0.3 .. 0.9
    warmth: 0.4 .. 0.8
  }

  age: 18 .. 65

  -- Engine generates a name, backstory, etc. from this template
  -- Lonni defines the distributions; the engine instantiates
}

This is a natural extension of the range syntax. Templates define distributions; the engine samples from them. Hand-authored characters override specific values; procedural characters fill in the gaps.

Recommendation: Include template as a construct from the start. It uses the same syntax as character but allows ranges where character requires concrete values.


13. Build Plan

Phase 1: Grammar + Parser (2 weeks)

  • Define AST types in syntax/ast.rs
  • Implement logos lexer with prose block handling
  • Write LALRPOP grammar for all top-level constructs
  • Write LALRPOP grammar for expression language
  • Write LALRPOP grammar for behavior tree syntax
  • Parse test suite: one .sb file per construct, edge cases for expressions and prose blocks
  • Error recovery in parser for multi-error reporting

Deliverable: syntax module parses any .sb file into an AST. No resolution, no validation.

Phase 2: Resolution + Validation (2 weeks)

  • Name table construction from parsed ASTs
  • use statement resolution with wildcard and group support
  • Qualified path resolution with kind checking
  • link resolution with bidirectional handling and asymmetric value merging
  • Override merge engine (recursive structural merge, remove, append)
  • include expansion for capsets and need templates
  • use_subtree inlining with cycle detection
  • Semantic validation (ranges, types, required fields, cross-entity consistency)
  • Diagnostic formatting with miette or ariadne
  • Fuzzy matching for unresolved name suggestions

Deliverable: resolve module. Feed it ASTs, get resolved types or rich error messages.

Phase 3: Public API + CLI (1 week)

  • Project::load(path) API
  • Query interface: find characters, list relationships, filter by trait
  • sb CLI binary: sb validate, sb query, sb inspect
  • Watch mode for continuous validation (sb watch)

Deliverable: Working toolchain. Lonni can start writing .sb files and validate them from terminal (with Sienna's help).

Phase 4: Schema + Seed Content (1 week)

  • Write schema/ files: core enums, need templates, capability sets, action registry
  • Write 3-5 seed characters with full cross-referencing
  • Write 2-3 institutions (bakery, school, household)
  • Write example life arcs, schedules, behavior trees
  • Write example relationships, conditions, locations
  • Validate the whole thing end-to-end

Deliverable: A complete, small storybook that exercises every language feature. Proves the grammar works for real content.

Phase 5: Editor MVP (3-4 weeks)

  • eframe app scaffolding with panel layout
  • Character editor: form fields + prose editing
  • Filesystem watching + live re-resolve
  • Diagnostics panel with click-to-navigate
  • Transparent git (auto-commit on save)
  • Relationship map (force-directed graph, click to edit)
  • Life arc editor (node graph)
  • Schedule builder (timeline view)

Deliverable: Lonni can create and edit characters, relationships, and schedules in a visual tool. Behavior trees and advanced constructs come in Phase 6.

Phase 6: Editor Completeness + Engine Integration (2-3 weeks)

  • Behavior tree visual editor (node graph)
  • Location browser with tile map
  • Cross-reference navigation (click a name → jump to its definition)
  • GitHub integration: issues, PRs, comments via octocrab
  • Entity cross-linking in issues (tag issues to characters/systems)
  • Engine integration: storybook → Bevy ECS component mapping
  • Hot-reload pipeline for development
  • Compiled binary format for release builds

Deliverable: Full pipeline from content authoring to running simulation. Lonni never has to leave the editor for project management.


14. Risk Assessment

Risk Likelihood Impact Mitigation
LALRPOP > ambiguity between BT sigil and comparison op Medium Low Separate grammar productions for tree context vs expression context
Prose block lexer mode causes edge cases Medium Medium Extensive test suite for weird prose content (contains ---, contains {, etc.)
Override merge semantics are underspecified for deep nesting High Medium Define precise merge rules in a spec doc before implementing; property-test the merge engine
Lonni finds the file-based workflow frustrating Medium High Prioritize the editor (Phase 5); the CLI is the fallback, not the primary tool
Schema evolution breaks existing content Low (early) High (later) Start with additive-only evolution; write migration tools if needed
Behavior tree syntax is too terse for Lonni Low Low She'll use the visual editor; text syntax is for Sienna and git diffs
500-entity storybook is slow to resolve Low Medium Profile early; resolution is O(n) in definitions, should be fine for thousands of files
Expression language needs arithmetic eventually Medium Medium Design the grammar to be extensible; add operators later if needed

15. Summary

Storybook is a domain-specific language designed around the specific data shapes of Aspen's agent simulation: state machines for life arcs, hierarchical trees for behavior, temporal blocks for schedules, graph structures for relationships, cross-cutting modifiers for conditions, and prose for the narrative soul of the world.

The cross-referencing system uses filesystem-derived namespaces, use imports, qualified paths, bidirectional link instantiation, and structural override merging. Everything resolves in a multi-pass pipeline that produces rich error messages when things go wrong.

The implementation is four Rust crates: syntax (parser), resolve (semantics), storybook (public API), and editor (egui tool). The engine consumes the same crate, ensuring the content pipeline is unified from authoring to runtime.

Lonni gets a visual editor that hides the grammar behind forms, node graphs, and timeline views. Sienna gets a powerful text format that diffs well in git, validates in CI, and maps directly to the simulation architecture. The village gets to live.