Scenario JSON schema reference
LivingSim.Core does not require JSON. You can use the C# API directly. The scenario JSON format is an optional authoring layer that seeds a
SimulationEngine.
This document is the field-by-field reference for that optional format — the
shape samples/Scenarios/*.json packs (e.g.
village-council-dispute.json)
follow. For running a loaded engine (ticking, inspecting, snapshotting), see
RUNNING_THE_CORE.md.
Two ways to drive the engine
The engine's real contract is the C# public API. JSON is one convenience adapter on top of it.
1. C# directly — no JSON at all:
using LivingSim.Core;
var stamp = new DateTime(2026, 1, 1, 12, 0, 0, DateTimeKind.Utc);
var engine = new SimulationEngine(seed: 434);
engine.GetOrCreateCharacter("brann").MoveTo("council-hall");
engine.RecordEvent(new WorldEvent(
EventType.Accuse, "mara", "brann", "council-hall", -30, stamp));
engine.Step(stamp);
var snapshot = engine.GetSnapshot();
2. JSON authoring — ScenarioLoader.LoadFromJson returns a ready-to-run engine:
using LivingSim.Core.Authoring;
var result = ScenarioLoader.LoadFromJson(File.ReadAllText("village-council-dispute.json"));
if (!result.IsValid)
{
foreach (var error in result.Errors)
Console.WriteLine($"{error.Path}: {error.Message}"); // path-keyed, e.g. "scenario.characters[2].placeId"
return;
}
var engine = result.Engine!; // identical to one built by hand through the same calls
Both produce the same kind of SimulationEngine. Every JSON section just calls
a public engine method — the loader performs no private state hacks (see the
section → API map below), so a seeded engine is
observationally identical to one a host materialised at runtime.
When to reach for JSON: you want to declare an initial world as data (cast, relationships, secrets, norms) instead of writing setup code, and optionally ship host-only display metadata alongside it. When to skip it: you're constructing the world programmatically, or generating it from your own game data — call the C# API and ignore this format entirely.
What the JSON does not do
- It does not script behaviour. A pack only seeds starting state. What
happens next is the host's job (record events, call
Step, runAutoBehavior) and the engine's computation. Thevillage-council-hearingdemo program scripts the hearing's beats — the JSON pack does not. - It does not advance the clock.
seedEvents/rumourSeedsappend to the event log, but the engine'sCurrentTickstays at0after loading. Advancing simulation time is a host concern (AdvanceTime/Step). hostMetadatanever touches the simulation. It is opaque display data for your host UI (see hostMetadata).
Loading API
ScenarioLoader.LoadFromJson(string json, BehaviorTuning? tuningOverride = null, int? seedOverride = null, bool strict = false) returns a ScenarioLoadResult:
| Member | Type | Meaning |
|---|---|---|
IsValid |
bool |
true when the engine built; false when any validation error was collected |
Engine |
SimulationEngine? |
the ready-to-run engine on success; null on failure |
Errors |
IReadOnlyList<ScenarioValidationError> |
every error (collected, not first-only); each has a Path (e.g. scenario.legal[0].heldBy) and a Message |
HostMetadata |
JsonElement? |
the parsed hostMetadata tree, exposed even on failure; never read into Core |
tuningOverride— wins overscenario.tuningPreset; pass a customBehaviorTuning(e.g.BehaviorTuning.Default.WithAutoBehaviorEnabled()).seedOverride— wins overscenario.rngSeed(used by the headless runner's multi-seed mode).strict— whentrue,hostMetadatamust be a JSON object (or absent/null), else a$.hostMetadataerror. Defaultfalseaccepts any JSON there.
JSON conventions
- Property names are
camelCase(characterId,placeId,rngSeed,topicKind). - Enum values are written as
camelCasestrings —"default","hiddenAffiliation","moralLine","place","detained","apologize". String names are the portable authoring style and the only form the shipped samples use. (Integer enum values also deserialize — the converter allows them — but every value, string or numeric, is then checked against the enum: any value that isn't a defined member is rejected with a path-keyed error.) - Unknown fields fail loudly. A typo like
placIdinstead ofplaceIdproduces a structured error at path"$"(the loader usesUnmappedMemberHandling.Disallow). versionmust be1.- A
nullor omitted section equals an empty array — include only what you need. The minimal legal file is{ "version": 1, "scenario": { "characters": [] } }. - Character, place, and faction ids — and references to them — are compared
case-insensitively, and must be unique across the character + faction
namespaces (a faction id may not collide with a character id). Section-local
ids such as
normIdandsecretIdare treated as stable opaque ids — their duplicate checks are case-sensitive — so write each as a fixed string.
Envelope
{
"version": 1, // required; must be 1
"scenario": { /* … */ }, // the part loaded into the engine
"hostMetadata": { /* … */ } // optional; host-only display data, never simulation truth
}
scenario — the part loaded into the engine
Section → Core API map
Each section maps 1:1 to a public engine call. This is the whole of what loading does:
| Section | Core API it drives | Notes |
|---|---|---|
rngSeed |
new SimulationEngine(new SeededSimulationRandom(seed), tuning) |
deterministic seed; default 0 |
tuningPreset |
BehaviorTuning.Default / .Gameplay / .Cinematic |
tuningOverride arg wins |
places |
declares the place id space | characters MoveTo these ids |
characters |
GetOrCreateCharacter(id), .MoveTo(placeId), .Personality = … |
roleId is not read (host-only) |
relationships |
actor.Relationships.Adjust(to, affinity, trust) |
directional (from → to) |
factions |
GetOrCreateFaction(id, displayName), AddFactionMember(id, member) |
|
resources |
character.Resources.ApplyMoneyDelta(money), .ApplyItemDelta(item, qty) |
|
beliefs |
character.Beliefs.Add(new Belief(…)) |
belief substrate |
identityAnchors |
character.IdentityAnchors.Add(new IdentityAnchor(…)) |
identity substrate |
trauma |
character.Trauma.Add(new TraumaState(…)) |
|
legal |
character.Legal.Detain/Arrest/Incarcerate/ConditionallyRelease(…) |
legal state |
norms |
engine.SetSocialNorms(List<SocialNorm>) |
norms |
secrets |
engine.SetSecrets(List<SecretEntry>) |
secret registry |
seedEvents |
engine.RecordEvent(new WorldEvent(…)) |
recorded first |
rumourSeeds |
engine.RecordEvent(new WorldEvent(Gossip, …)) |
recorded after seedEvents |
required below means the loader emits a validation error if the field is
missing or blank. Everything else is optional with the listed default.
rngSeed / tuningPreset
"rngSeed": 434, // int, default 0 — the deterministic RNG seed
"tuningPreset": "default" // "default" | "gameplay" | "cinematic" (default: "default")
places
"places": [ { "placeId": "council-hall" } ]
| Field | Type | Required | Notes |
|---|---|---|---|
placeId |
string | ✓ | must be unique; characters reference it via placeId |
Places have no state of their own here — they declare the valid id space that
characters[].placeId, trauma[].associatedPlaceId, place-scoped norms, etc.
are checked against.
characters
"characters": [
{
"characterId": "elin",
"placeId": "council-hall",
"roleId": "council-chair",
"personality": { "openness": 60, "conscientiousness": 80, "agreeableness": 70, "neuroticism": 30 }
}
]
| Field | Type | Required | Notes |
|---|---|---|---|
characterId |
string | ✓ | unique across characters and factions |
placeId |
string | — | must reference a declared place; sets the character's location |
personality |
object | — | OCEAN + extended traits, see below |
roleId |
string | — | ignored by the engine — opaque host-side label, never validated, never affects state |
personality fields are each 0–100, default 50 (neutral):
openness, conscientiousness, extraversion, agreeableness, neuroticism,
intellect, focus, honesty, temperance.
relationships
"relationships": [ { "from": "elin", "to": "brann", "affinity": 5, "trust": 0 } ]
| Field | Type | Required | Notes |
|---|---|---|---|
from |
string | ✓ | the holder of the relationship (declared character) |
to |
string | ✓ | the subject (declared character); must differ from from |
affinity |
number | — | default 0 |
trust |
number | — | default 0 |
Directional: from's feelings toward to. Author both rows for a mutual pair.
factions
"factions": [ { "factionId": "village-council", "displayName": "Village Council", "members": ["elin", "mara", "tovin"] } ]
| Field | Type | Required | Notes |
|---|---|---|---|
factionId |
string | ✓ | unique; must not collide with a character id |
displayName |
string | — | |
members |
string[] | — | each must be a declared character |
resources
"resources": [ { "characterId": "brann", "money": 20, "items": { "grain": 3 } } ]
| Field | Type | Required | Notes |
|---|---|---|---|
characterId |
string | ✓ | declared character |
money |
int | — | applied as a delta from 0 |
items |
object (string→int) | — | item id → quantity; blank item ids rejected |
beliefs
"beliefs": [
{
"characterId": "walter", "id": "walter-defend-belief", "subject": "release",
"expectedEventType": "release", "expectedTickWindowStart": 0, "expectedTickWindowEnd": 50,
"confidence": 80, "expectedActorId": "warden", "recordedTick": 0
}
]
| Field | Type | Required | Notes |
|---|---|---|---|
characterId |
string | ✓ | the believer |
id |
string | ✓ | belief id |
subject |
string | ✓ | what the belief is about |
expectedEventType |
EventType | ✓ | see EventType |
expectedTickWindowStart / expectedTickWindowEnd |
long | ✓ | end must not precede start |
confidence |
number | ✓ | |
expectedTargetId / expectedActorId |
string | — | declared characters |
expectedPlaceId |
string | — | declared place |
recordedTick |
long | — | default 0 |
identityAnchors
"identityAnchors": [
{ "characterId": "elin", "name": "Fair Mediator", "kind": "moralLine",
"description": "Sees themselves as the one who keeps the council fair.", "strength": 75 }
]
| Field | Type | Required | Notes |
|---|---|---|---|
characterId |
string | ✓ | |
name |
string | ✓ | |
kind |
IdentityAnchorKind | ✓ | profession | belief | relationshipRole | moralLine |
description |
string | — | |
strength |
number | ✓ | |
recordedTick |
long | — | default 0, non-negative |
trauma
"trauma": [ { "characterId": "brann", "severity": 40, "associatedPlaceId": "council-hall", "avoidedTopics": ["grain-shortage"] } ]
| Field | Type | Required | Notes |
|---|---|---|---|
characterId |
string | ✓ | |
severity |
int | ✓ | |
associatedSubjectId |
string | — | declared character |
associatedPlaceId |
string | — | declared place |
avoidedTopics |
string[] | — | no blank entries |
recordedTick |
long | — | default 0, non-negative. The trauma loses a point of severity every 50 ticks after this tick, so a tick later than the world's start delays its first fade until that tick plus 50. |
legal
"legal": [ { "characterId": "brann", "status": "detained", "heldBy": "mara", "sinceTick": 0 } ]
| Field | Type | Required | Notes |
|---|---|---|---|
characterId |
string | ✓ | one legal row per character |
status |
LegalStatus | ✓ | free | detained | arrested | incarcerated | conditionalRelease |
heldBy |
string | required unless free |
declared character (the authority); must be absent for free |
sinceTick |
long | required unless free |
non-negative; must be absent for free |
electronicMonitoring |
bool | — | default false |
Impossible pairs (a free row carrying heldBy/sinceTick, or a non-free
row missing them) are rejected at validation time.
norms
"norms": [
{ "normId": "honour-council-rulings", "scopeKind": "place", "scopeId": "council-hall",
"upheldByEventTypes": ["apologize"], "violatedByEventTypes": ["insult", "harass"] }
]
| Field | Type | Required | Notes |
|---|---|---|---|
normId |
string | ✓ | unique |
scopeKind |
NormScopeKind | ✓ | global | place |
scopeId |
string | depends | must be null for global; required + a declared place for place |
upheldByEventTypes |
EventType[] | — | event types that uphold the norm |
violatedByEventTypes |
EventType[] | — | event types that violate it |
secrets
"secrets": [
{ "secretId": "brann-rival-circle", "ownerId": "brann", "topicKind": "hiddenAffiliation",
"topicId": "rival-trade-circle", "importance": 75, "risk": 65, "suspectedBy": ["tovin"] }
]
| Field | Type | Required | Notes |
|---|---|---|---|
secretId |
string | ✓ | unique |
ownerId |
string | ✓ | declared character |
topicKind |
SecretTopicKind | ✓ | concealedAction | hiddenAffiliation | privateRelationship | withheldKnowledge |
topicId |
string | ✓ | the subject (e.g. a faction id for hiddenAffiliation) |
importance |
int | — | default 0 |
risk |
int | — | default 0; surfaces as the discovery event's impact in the demo |
knownBy |
string[] | — | declared characters; the owner is added automatically |
suspectedBy |
string[] | — | declared characters who suspect but don't know |
A
hiddenAffiliationsecret withtopicId= a faction id is the canonical "private allegiance" shape — the seed behind thevillage-council-hearingdemo's goal flip.
seedEvents
Pre-recorded WorldEvents appended to the event log in authoring order. Their
timestamp is deterministic (2026-01-01T12:00:00Z + tick seconds); the clock
does not advance.
"seedEvents": [ { "tick": 0, "type": "help", "actorId": "ada", "targetId": "bo", "placeId": "studio", "impact": 20 } ]
| Field | Type | Required | Notes |
|---|---|---|---|
tick |
long | ✓ | non-negative, and at most the last whole second a timestamp can carry from the base stamp (about 2.5 × 10¹¹, the end of the year 9999); ordering + timestamp offset |
type |
EventType | ✓ | see EventType |
actorId |
string | ✓ | declared character (every event has an actor) |
targetId |
string | — | declared character |
placeId |
string | — | declared place |
impact |
int | — | default 0 |
itemId / quantity / unitPrice / jobId |
— | — | for material/employment events |
rumourSeeds
Gossip-shaped sugar over seedEvents: each expands to a Gossip WorldEvent
(spreaderId → actor, aboutId → target), recorded after all seedEvents.
"rumourSeeds": [ { "tick": 0, "spreaderId": "tovin", "aboutId": "brann", "placeId": "council-hall", "impact": -14 } ]
| Field | Type | Required | Notes |
|---|---|---|---|
tick |
long | ✓ | non-negative, with the same upper bound as seedEvents |
spreaderId |
string | ✓ | declared character (the gossip's actor) |
aboutId |
string | ✓ | declared character (the subject) |
placeId |
string | — | declared place |
impact |
int | — | default 0 |
hostMetadata
Optional, top-level (a sibling of scenario, not inside it). The loader
parses it for shape only and never mutates the engine from it — it is exposed
on ScenarioLoadResult.HostMetadata for your host UI to read. Snapshot
byte-identity holds with or without any hostMetadata content.
"hostMetadata": {
"displayTitle": "Village Council Dispute",
"endings": [ { "endingId": "council-settled", "displayLabel": "The council settles the dispute." } ],
"proseTemplates": [ { "templateId": "opening", "text": "The village gathers in the council hall." } ],
"sourceNotes": { "sourceTitle": null, "license": null, "credits": "Original generic sample content." }
}
The shape is not validated against a fixed schema (it's free-form for the
host). The samples/ScenarioHost/ reference host reads displayTitle, ending
labels, and credits as a worked example. Endings here are display labels the
host may render — never an automatic verdict; the simulation never reads them.
EventType values
type / expectedEventType / norm event-type lists accept any member of the
~106-verb EventType taxonomy, written camelCase (help, accuse,
apologize, insult, harass, gossip, revealSecret, betray, steal,
detain, …). The authoritative list is
src/LivingSim.Core/EventType.cs
(grouped by category in EventTaxonomy). An undefined value produces a
path-keyed validation error listing the valid names.
Validation behaviour
- All errors are collected, not just the first — one load surfaces the full
picture. Each
ScenarioValidationErrorcarries aPath(scenario.<section>[i].<field>, JSON-pointer style) and aMessage. - The engine is built only when the error list is empty — there is no partial-load state to leak.
- Every cross-reference (
placeId,characterId, faction members, norm scope, secret owners…) is checked against the declared id space.
Worked example
samples/Scenarios/village-council-dispute.json
exercises every section above in one file — the seed behind the
village-council-hearing
canonical demo. It declares the council hall, four characters (one with a
personality + identity anchor), a faction, seeded distrust toward brann,
brann's detention, trauma, a place-scoped norm, a hiddenAffiliation secret
tovin suspects, and a rumour tovin spreads — then a hostMetadata block of
display labels the host may render. Loading it never advances the clock or runs
behaviour; the demo program does that.
See also
RUNNING_THE_CORE.md— load → tick → inspect → snapshot; the host/Core boundary.- HTML API reference — build with
scripts/build-api-docs.sh(DocFX), then open_site/index.html. Covers the publicSimulationEngine/WorldEvent/ScenarioLoader/BehaviorTuningsurface. scenario-pack-schema-extensions-design.md— the design/decision history behind these sections (not a reference).