Authoring grammar

JSON document layout, nodes, choices, gates, effects, and conventions for Blackbox story projects.

A Blackbox game is a set of authored JSON documents. scenario.json lists chapters; each chapter file holds nodes; nodes hold text blocks and choices. Edit content with patch_documents (granular) or save_documents (whole document); add a chapter with add_chapter; then lint_project and simulate_project to validate and explore reachability.

These shapes mirror the canonical JSON wire format. Use lint_project for validation and the MCP server for patch_documents, save_documents, and add_chapter.

Documents

scenario.jsoncom.blackbox.scenario

FieldType
formatVersionnumber (1)
titlestring
revisionstring, e.g. "1.0" (story version label, not the editor revision)
startNodeIdnode id for new games in single-file scenarios; omitted when using chapters
nodesobject of nodeId -> Node for single-file scenarios; omitted when using chapters
randomSeedinteger (optional)
defaultStatsobject of stat name -> integer, e.g. { resolve: 2, insight: 2 }
itemsRefstring path, default items.json
charactersRefstring path, default characters.json
relationshipOverridesobject of characterId -> relationship metric overrides for new games (optional)
assetsRefstring path, default assets.json
catalogRefstring path to events/flags catalog, e.g. catalog.json (optional)
libraryRefstring path, default library.json
cookRefstring path, default bundle.cook.json
deathNodeinline node used as the default death screen (optional)
hooks{ onCommand?: Effect[], onNodeEnter?: Effect[] } scenario-wide hooks: onCommand runs once per player command, onNodeEnter on every node arrival before the node's own onEnter (optional)
chaptersarray of { id, title, ref } in play order

chapter_<id>.jsoncom.blackbox.chapter

FieldType
formatVersionnumber (1)
idstring, matches the scenario chapter id
titlestring
startNodeIdstring, id of the node the chapter opens on
deathNodeIdnode id for this chapter's death fallback; requires scenario deathNode (optional)
nodesobject of nodeId -> Node (see node shape)

items.jsoncom.blackbox.items

FieldType
itemsobject of itemId -> { id, name, description, examineText?, iconRef?, actions? }

characters.jsoncom.blackbox.characters

FieldType
charactersobject of characterId -> { id, name, subtitle?, color?, portraitRef?, voiceRef?, relationships? }

assets.jsoncom.blackbox.assets.bundle

FieldType
texturesobject of id -> { src, usage?: internal|external }
musicobject of id -> { src, loop?: true, usage?: internal|external }
sfxobject of id -> { src, usage?: internal|external }
defaultChoiceSfxsfx id played for choices that omit sfx (optional)

Upload the binary files with upload_media before referencing them here.

catalog.jsoncom.blackbox.catalog

FieldType
eventsobject of eventId -> { title, description?, internal? } (story beats)
flagsobject of flagId -> { title, description?, internal? } (branchable state)

Referenced by scenario.catalogRef. patch_documents collections 'event' and 'flag' write here.

library.jsoncom.blackbox.library

FieldType
snippetsobject of id -> reusable text block(s); reference as "@<id>" or { "$snippet": "<id>", params?: { KEY: "literal text" } }; params substitute {param.KEY} placeholders
templatesobject of id -> node template; reference via node "$extends"
conditionsobject of name -> named gate; reference via { "type": "condition", "id": "<name>" }

Node

A single story beat. Key in the chapter's nodes map must equal its id.

FieldType
idstring
titlestring
mode"normal" (default) | "game_over" (ends the run) | "ending" (offers restart)
onEnterarray of Effect, applied when the node is entered
redirectarray of { when?: Gate, unless?: Gate, goto: nodeId } conditional forwards evaluated after onEnter; first passing rule moves the player (hub dispatch without fake choices; chains cap at 8 hops)
backgroundRefstring texture id (optional)
textarray of TextBlock
choicesarray of Choice
$extendsstring template id from library.templates (optional, advanced)
$mergeobject controlling array-merge behaviour when $extends is set (advanced)

Text block

Either a snippet reference ("@<id>" string, or { "$snippet": "<id>", params? }) or an inline block keyed by kind.

Kinds: paragraph, dialogue, thought, stage_direction.

FieldType
kindone of the kinds above
textstring
speakercharacter id (for dialogue/thought)
side"left" | "right" | "center" (speaker placement)
whenGate; block only shows when the gate passes
unlessGate; block is hidden when the gate passes
elsestring shown instead when when fails
emotionhost styling mood tag (optional)
stylestring[] of presentation tokens for hosts, e.g. ["quoted", "terminal"] (optional)
actorcharacter id; sugar for when: { type: hasFlag, flag: actor<id> }

Choice

An option presented at a node.

FieldType
idstring (unique within the node)
labelstring shown to the player
sfxsfx id played on select (optional)
gototarget nodeId in the SAME chapter (omit when using action)
actioncross-chapter / menu transition instead of goto (see actions). e.g. { type: gotoChapter, chapterId, nodeId? }
effectsarray of Effect applied when the choice is taken
requiresGate; when unmet the choice is hidden, or disabled if disabledReason is set
whenGate; the choice only appears when it passes
unlessGate; the choice is hidden when it passes
disabledReasonstring shown when requires is unmet (keeps the choice visible but disabled)
whenDisabledReasonstring shown when when is unmet but you still want it visible
unlessDisabledReasonstring shown when unless matches but you still want it visible
checkSkillCheck; resolves the choice via a dice roll (see check)

A choice needs at least one resolution path: effects, goto, action, or check. Use action or check instead of normal goto; effects may accompany goto or run before a check.

Gates

A gate is either a single { type, ... } node or an ARRAY of nodes (array = logical AND). Most leaf gates accept an optional disabledReason.

TypeShape
hasItem{ type: hasItem, itemId, count?: 1, disabledReason? }
hasFlag{ type: hasFlag, flag, value?: any, disabledReason? }
statGte{ type: statGte, stat, value, disabledReason? }
statLte{ type: statLte, stat, value, disabledReason? }
statEq{ type: statEq, stat, value, disabledReason? }
visited{ type: visited, nodeId, disabledReason? }
atNode{ type: atNode, nodeId, disabledReason? }
relationshipGte{ type: relationshipGte, characterId, metric, value, disabledReason? }
relationshipLte{ type: relationshipLte, characterId, metric, value, disabledReason? }
relationshipEq{ type: relationshipEq, characterId, metric, value, disabledReason? }
actorPresent{ type: actorPresent, characterId, disabledReason? }
condition{ type: condition, id, disabledReason? } — reference a named gate in library.conditions
all{ type: all, conditions: Gate[] }
any{ type: any, conditions: Gate[] }
not{ type: not, condition: Gate }

Effects

Applied via node.onEnter, choice.effects, skill-check branches, or scenario hooks. *Expr variants take a string expression.

TypeShape
setFlag{ type: setFlag, flag, value } or { type: setFlag, flag, valueExpr }
modifyStat{ type: modifyStat, stat, amount } or { type: modifyStat, stat, amountExpr }
addItem{ type: addItem, itemId, count?: 1 } or { ..., countExpr }
removeItem{ type: removeItem, itemId, count?: 1 } or { ..., countExpr }
addEvent{ type: addEvent, eventId }
playMusic{ type: playMusic, track }
stopMusic{ type: stopMusic }
playSfx{ type: playSfx, sfx }
roll{ type: roll, sides?: 20, label?, storeFlag? }
modifyRelationship{ type: modifyRelationship, characterId, metric, amount } or { ..., amountExpr }
setActorPresent{ type: setActorPresent, characterId, value }

Item actions

Actions attached to items. They use the same gates/effects/goto semantics as choices and consume one item by default.

FieldType
idstring (unique within the item)
labelstring shown to the player
requiresGate; when unmet the action is hidden, or disabled if disabledReason is set
whenGate; action only appears when it passes
unlessGate; action is hidden when it passes
disabledReasonstring shown when requires is unmet
whenDisabledReasonstring shown when when is unmet but you still want it visible
unlessDisabledReasonstring shown when unless matches but you still want it visible
effectsarray of Effect applied when the item action is used
gototarget nodeId in the current chapter (optional)
consumebool; removes one item after use when true (default true)

Actions

Used on a choice instead of goto for non-local transitions.

TypeShape
gotoChapter{ type: gotoChapter, chapterId, nodeId? } — nodeId defaults to that chapter's startNodeId
restartGame{ type: restartGame, startNodeId }
openMainMenu{ type: openMainMenu }
openLoadMenu{ type: openLoadMenu }

Skill check

A skill check on a choice. Binary form rolls a stat versus a difficulty; tiered form maps the total onto ordered outcome bands via outcomes.

FieldType
statstring stat name
difficultyinteger target number (binary form; omit when using outcomes)
sidesinteger die sides (optional, defaults to 20)
modifierexpression added to the roll (optional)
labelstring shown during the check (optional)
rollMode"normal" (default) | "advantage" | "disadvantage"
maxAttemptsinteger (optional)
onSuccess{ effects?: Effect[], goto?: nodeId } (binary form)
onFailure{ effects?: Effect[], goto?: nodeId } (binary form)
onExhausted{ effects?: Effect[], goto?: nodeId } when maxAttempts is used (optional)
outcomesarray of { min?, label?, success?, effects?, goto? } tiers, best-first with strictly descending min, ending in a catch-all with no min; first tier with total >= min wins. Mutually exclusive with difficulty/onSuccess/onFailure

Expressions

String expressions are used by effect *Expr fields, skill-check modifier, and text interpolation.

FormShape
string"stat.logic + dice(4)"
astadvanced JSON expression objects with lit, var, call, or op
VariableMeaning
stat.<name>number; stat value
item.<itemId>number; inventory count
flag.<name>bool/number/string; missing flag is false
visited.<nodeId>bool; whether node was visited
relationship.<characterId>.<metric>number; declared relationship score
FunctionMeaning
stat(name)number; stat value
hasItem(id, count?)bool; inventory has count (default 1)
itemCount(id)number; inventory count
hasFlag(flag, value?)bool; flag present / equals value
visited(nodeId)bool; node was visited
relationship(characterId, metric)number; declared relationship score
not(x)bool; negation
random(min, max)number; inclusive random integer, advances RNG
dice(sides)number; rolls one die, advances RNG
OperatorsMeaning
==, !=, eq, neqequality
>, >=, <, <=, gt, gte, lt, ltecomparison
+, -, *, /arithmetic
`and, &&, or,
  • Boolean results can be used as numbers in modifiers: false is 0, true is 1.
  • Effect expressions and skill-check modifiers may use random() and dice(); gates and text interpolation are read-only and cannot use RNG.
  • String literals may use single or double quotes.

Conventions

  • Name a new chapter file chapter_<id>.json and register it in scenario.chapters.
  • Convention: the chapter's start node id is <chapterId>_start.
  • Node ids are unique within a chapter; goto stays within the chapter, gotoChapter crosses chapters.
  • Reachable endings use node mode "ending"; failure states use "game_over".
  • Run lint_project for structural errors and simulate_project to confirm endings are reachable.