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.
4.4 link — Relationship Instantiation
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:
- When processing Martha's file, it registers
Martha <-> David via Spousal - When processing David's file, it checks whether David also declares a link to Martha
- If David doesn't declare it: the relationship is automatically bidirectional. David inherits it.
- 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
ageandspecies)
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:
iskeyword 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, nostock * 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 revertunder 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:
- Loads the storybook directory
- Parses, resolves, validates
- Converts resolved types into Bevy ECS components and resources
- 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:
- Re-parse only the changed files
- Re-run resolution (full, since cross-references may have changed — this should be fast enough for hundreds of files)
- Diff the resolved state against the current ECS state
- 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
.sbfile 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
usestatement resolution with wildcard and group support- Qualified path resolution with kind checking
linkresolution with bidirectional handling and asymmetric value merging- Override merge engine (recursive structural merge, remove, append)
includeexpansion for capsets and need templatesuse_subtreeinlining with cycle detection- Semantic validation (ranges, types, required fields, cross-entity consistency)
- Diagnostic formatting with
mietteorariadne - 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
sbCLI 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)
eframeapp 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.