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, run AutoBehavior) and the engine's computation. The village-council-hearing demo program scripts the hearing's beats — the JSON pack does not.
  • It does not advance the clock. seedEvents / rumourSeeds append to the event log, but the engine's CurrentTick stays at 0 after loading. Advancing simulation time is a host concern (AdvanceTime / Step).
  • hostMetadata never 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 over scenario.tuningPreset; pass a custom BehaviorTuning (e.g. BehaviorTuning.Default.WithAutoBehaviorEnabled()).
  • seedOverride — wins over scenario.rngSeed (used by the headless runner's multi-seed mode).
  • strict — when true, hostMetadata must be a JSON object (or absent/null), else a $.hostMetadata error. Default false accepts any JSON there.

JSON conventions

  • Property names are camelCase (characterId, placeId, rngSeed, topicKind).
  • Enum values are written as camelCase strings — "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 placId instead of placeId produces a structured error at path "$" (the loader uses UnmappedMemberHandling.Disallow).
  • version must be 1.
  • A null or 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 normId and secretId are 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": [ { "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 hiddenAffiliation secret with topicId = a faction id is the canonical "private allegiance" shape — the seed behind the village-council-hearing demo'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 ScenarioValidationError carries a Path (scenario.<section>[i].<field>, JSON-pointer style) and a Message.
  • 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 public SimulationEngine / WorldEvent / ScenarioLoader / BehaviorTuning surface.
  • scenario-pack-schema-extensions-design.md — the design/decision history behind these sections (not a reference).