Baseline Engine: writing stories
Baseline Engine runs branching, immersive interactive fiction. You write a story as a folder of JSON files; the engine loads them, and the player types whatever they want. The engine maps that input to one of the intents you defined for the current scene. State accumulates. Intents gate on what's been said and done. The same story can have many endings.
The cool part: players can phrase actions however they want. The engine's intent-recognition step lets them type "let me see if she's lying" or "press her on it" or "fuck off, lady" and route all three to the press intent you wrote. The story is yours; the engine just removes the friction of fixed-vocabulary text adventures.
This page documents every feature you can use as a story author. Examples are taken from real shipped stories or simplified to fit.
1. Overview
A story is a directory of JSON. There is one manifest.json at the top and a nodes/ subdirectory holding one JSON file per scene. The engine loads all of it on server boot and on demand; a player session lives entirely in the browser.
Every interaction follows the same loop:
- The engine renders the current node's text.
- The player types a sentence in their own words.
- The engine asks the AI which of the node's currently-available intents best matches that sentence.
- The matched intent's effects (state changes, item moves, transitions) apply.
- The next node renders, often with conditional text reflecting what the player has done.
Three things make this more than a chat-with-a-bot: gates that hide intents until they're earned; numeric state that tracks pressure, trust, time, anything you want; and forced transitions that let the world react without a player input.
2. Quick start
The smallest publishable story has two files. Drop them at stories/my_first_story/:
// manifest.json
{
"id": "my_first_story",
"title": "A Quiet Room",
"description": "Just the room you're in.",
"authorName": "Your Name",
"authorId": "your_handle",
"startNode": "intro",
"language": "en",
"date": "2026-05-01"
}
// nodes/intro.json
{
"id": "intro",
"text": "You are in a quiet room. There is a window.",
"intents": [
{
"id": "look_window",
"intent_description": "Look out the window",
"ai_intent_helper": "The user wants to look out the window or look outside.",
"action": "text",
"response": "The street below is empty. The streetlight is humming."
}
]
}
Restart the engine (or call the reload-story endpoint, see Publishing) and the story shows up at /play. Anyone can play it.
3. Story file structure
stories/
my_story/
manifest.json // story metadata
nodes/
intro.json // start node (must exist if startNode is "intro")
kitchen.json
end_good.json
end_bad.json
...
Rules:
- The folder name must match
manifest.id. - The
startNodein the manifest must be the filename of an existing node (without.json). - Node IDs must match their filename.
kitchen.jsoncontains a node with"id": "kitchen". - Use lowercase, alphanumeric, underscore-only IDs. ASCII only. The engine validates this in admin endpoints; the editor does too.
4. manifest.json
{
"id": "die_sprechmaschine",
"title": "Die Sprechmaschine",
"description": "Black Forest, 1924. A village priest built something in his cellar.",
"authorName": "W. Kammerer",
"authorId": "w_kammerer",
"startNode": "intro",
"language": "en",
"date": "2026-05-01"
}
Required: id, title, description, authorName, authorId, startNode. Recommended: language (ISO 639-1) and date (any format; the SEO page formats it).
The description is what shows up on the story selector card and in the SEO meta tags, social previews, and Google snippets. Write it as a hook, not a synopsis. One or two sentences.
5. Nodes
A node is a scene. Every node has an ID, a text body, and a list of intents the player can engage with from there. Optional fields cover revisits, conditional descriptions, and forced transitions.
{
"id": "kitchen",
"text": "The kitchen. A pot is on the stove.",
"text_revisit": "The kitchen, still warm.",
"text_conditionals": [
{ "if_state": { "lights_off": true }, "text": "Pitch dark. You hear breathing." }
],
"force_transition": [
{ "if_state": { "fire_started": true }, "target": "scene_kitchen_burns" }
],
"intents": [
/* see next section */
]
}
The order of resolution when the player enters a node:
- Engine checks
force_transition. If any entry's predicate matches the session, it routes to that node instead. (Used for innocent deaths, NPC collapses, time-outs.) - Engine selects the text body in this priority:
text_conditionalsfirst match wins; otherwisetext_revisitif visited before; otherwisetext. - Player input is collected and matched against the intents that gates allow.
6. Intents
An intent is a single thing the player can choose to do at the current node. It has an ID, a short description for the editor and lookups, an AI hint string (what kinds of phrases match it), an action, and optional gates and effects.
{
"id": "ask_about_brother",
"intent_description": "Ask if Anselm ever spoke about his brother",
"ai_intent_helper": "The user wants to ask about Joachim, the brother, the drowning, or family.",
"requires_state": { "knows_river_incident": true, "housekeeper_intro": true },
"set_state": { "knows_housekeeper_son": true, "housekeeper_warmed": true },
"action": "text",
"response": "She sits, finally. 'Joachim. He drowned the year of the great flood...'"
}
ai_intent_helper is the most important field for matching. Write it like an indirect description of what the user might be trying to do, in their words. The AI sees this list of helpers for the current node and picks the closest match. Vague helpers swallow neighboring intents. Specific helpers route correctly.
7. Action types
Every intent has an action. Four are supported:
action: "text"
Display a response and stay on the current node. Most intents are this kind.
{ "id": "examine_window", "action": "text", "response": "The window is dirty." }
action: "transition"
Move the player to target. The next node renders with all conditionals and forced transitions evaluated.
{ "id": "go_kitchen", "action": "transition", "target": "kitchen" }
action: "pickup"
Add item_id to the player's inventory. Once owned, the intent is hidden from future visits automatically. Use the response for the flavor text of taking the item.
{
"id": "take_key",
"action": "pickup",
"item_id": "brass_key",
"response": "You pocket the brass key."
}
action: "end_game"
End the run. The session is wiped from local storage. Use this on every ending node.
{
"id": "restart",
"action": "end_game",
"response": "STAND BY FOR THE NEXT TELEGRAM..."
}
8. Gates: hiding intents until earned
Gates are predicates on an intent. If any gate fails, the intent is invisible to the player for that turn (and not even shown to the AI). Gates compose as AND.
Item gates
"requires": ["lockbox_key"] // must own all
"requires_not": ["broken_lockbox_key"] // must NOT own any
Boolean state gates
"requires_state": { "door": "open" } // exact equality on state.door
"requires_not_state": { "mood": "angry" } // blocks if equal
Numeric state gates
"requires_state_gte": { "strain": 3 } // strain >= 3
"requires_state_lte": { "trust": 1 } // trust <= 1
"requires_state_neq": { "phase": "closed" } // any value not equal to closed
Numeric gates only match if the value is actually a number. state.strain = "high" will not pass requires_state_gte: { strain: 3 } even if you think it should.
Combine freely. The cruel-confrontation branch in Sprechmaschine looks like this:
{
"id": "confront_brandt_verdun_cruelly",
"requires_state": { "brandt_claims_verdun": true, "knows_wilhelm_died_june_1916": true },
"requires_state_gte": { "brandt_strain": 3 },
"set_state": { "brandt_exposed": true, "brandt_will_hang": true },
"action": "text",
"response": "..."
}
The same input ("confront him about Verdun") will match the cruel intent if Brandt's strain has climbed, the kind intent if it hasn't.
9. State, set, adjust
The session has a state object: any keys you want, any values. Two ways to write to it:
set_state (replace)
"set_state": { "door": "open", "mood": "calm" }
Boolean flags are conventional: set_state: { knows_priest_letter: true }. Use them for milestones the player has reached.
adjust_state (numeric add/subtract)
"adjust_state": { "strain": 1, "trust": -1 }
Reads the current value (treats missing or non-numeric as 0), adds the delta, writes back. Use for accumulating pressure, trust, fuel, time, anything where the count matters.
Both can appear on the same intent. Typical attitude pattern:
{
"id": "press_npc",
"action": "text",
"response": "Her face closes like a door.",
"adjust_state": { "lechner_strain": 1 }
},
{
"id": "sympathize_with_npc",
"action": "text",
"response": "She nods, once.",
"set_state": { "housekeeper_warmed": true },
"adjust_state": { "lechner_strain": -1 }
}
10. Items: pickup and consume
The player has an inventory array of item IDs. Two operations:
Pickup
Use action: "pickup" with item_id. The engine adds the item once and hides the intent on subsequent visits.
Consume
consume is a separate field on any intent (text, transition, pickup, end_game). It removes items at fire time. Useful for spending tokens or burning evidence.
{
"id": "burn_letter",
"action": "text",
"response": "You feed the letter into the stove.",
"requires": ["incriminating_letter"],
"consume": ["incriminating_letter"]
}
If the item isn't in inventory, consume is a no-op.
11. Node text and conditionals
A node's displayed text is chosen in this priority:
- First matching
text_conditionalsentry, if any. - Otherwise
text_revisit, if the player has been here before. - Otherwise
text.
"text_conditionals": [
{ "if_state": { "lights_off": true }, "text": "Pitch dark." },
{ "if_state_gte": { "strain": 4 }, "text": "Frau Lechner is at the table, breathing shallow." },
{ "if_not_state": { "first_visit": true }, "text": "The kitchen, again." }
]
Conditional types:
if_state: { key: value }: exact equality, all keys must matchif_not_state: { key: value }: blocks if equalif_state_gte: { key: number }: numeric >=if_state_lte: { key: number }: numeric <=
12. Text variants
For intents the player will hit many times, supply text_variants instead of (or alongside) response. The engine picks one at random per fire.
{
"id": "feel_wind",
"action": "text",
"text_variants": [
"The wind here is more like the slow breath of a planet.",
"You feel it before you hear it. Then you hear nothing.",
"Even your suit's fabric goes still and then flutters again."
]
}
Variants don't have to be tone-equivalent. You can use them to hint at progress or fold in occasional details.
13. Forced transitions
A node can declare that, on player entry, the engine should redirect to a different node if state matches. This is how innocent deaths, NPC collapses, and out-of-control beats happen. The world reacts without the player choosing.
{
"id": "priest",
"text": "Father Anselm is in the bed...",
"force_transition": [
{ "if_state": { "lechner_dead": true }, "target": "scene_lechner_collapsed" },
{ "if_state_gte": { "turn": 80 }, "target": "end_died_alone" }
],
"intents": [/* ... */]
}
Predicates accept the same forms as text_conditionals. First match wins.
14. Turn counter and time pressure
The engine increments session.turn on every resolved intent (every action the player takes). Read it like any numeric state key.
// Anselm becomes unreachable for deep confessions after turn 50:
{
"id": "ask_about_1898",
"requires_state_lte": { "turn": 50 },
"action": "text",
"response": "..."
}
// Whole case times out at turn 80:
"force_transition": [
{ "if_state_gte": { "turn": 80 }, "target": "end_died_alone" }
]
Use this when the world has its own clock the player can't control. Players who explore carelessly run out of time. Players who focus reach the end before the deadline.
15. Hidden intents and reveals
An intent with visible: false is hidden until the engine adds it to the session's revealedItems. The standard pattern: an "examine" intent with a reveals array that exposes one or more hidden intents.
{
"id": "examine_shelves",
"action": "text",
"response": "You rummage through the junk.",
"reveals": ["pickup_fuse"]
},
{
"id": "pickup_fuse",
"visible": false,
"action": "pickup",
"item_id": "heavy_fuse",
"response": "You pick up the Heavy Fuse.",
"text_description": "A Heavy Fuse is sitting on the shelf."
}
text_description on hidden intents is what the global "look around" command shows once the intent has been revealed.
16. Global intents
The engine adds three intents to every node automatically. The player can use these from anywhere, in their own words.
global_look_around: surfaces every visible intent'stext_descriptionglobal_inventory: lists itemsglobal_status: health, items, turn, visited locations
You don't define these. You just provide good text_description strings on your intents so look-around output reads cleanly.
17. How AI intent matching works
For every player input, the engine:
- Filters the current node's intents by all gates → list of currently-available intents.
- Sends those intents (id and ai_intent_helper) plus the player input to an AI provider.
- The AI returns the ID of the best-matching intent, or
"unknown"if nothing fits. - The engine caches that input → intent_id mapping in Redis. Subsequent identical inputs skip the AI.
What this means for authors:
- The AI never sees gated-out intents. You can write the same intent description twice with different gates and different responses; the AI only ever sees one of them.
- Write
ai_intent_helperto cover natural variations of how a player might ask. "The user wants to ask about / inquire about / press for information about X." - Don't fight the AI on phrases the player will never use. Write helpers that match what humans actually type.
- Two intents whose helpers overlap a lot will fight each other. Disambiguate.
18. The map editor
The editor lives at /editor/. It is a graphical builder for everything in this document: manifest fields, nodes, intents, gates, conditionals, forced transitions, items.
You can:
- Create new stories (login required so authorship is consistent)
- Edit existing nodes via forms instead of by-hand JSON
- Test-play in a side panel without leaving the editor
- Fork an existing story (clone it as your own)
- Import an existing story bundle (.json file) you wrote elsewhere
- Export your story as a single bundle file
- Publish your story so other players see it on /play
The editor is not the only way to write. You can write JSON files by hand, drop them into the bundle format below, and import them.
19. Importing and exporting bundles
A "bundle" is a single JSON file containing the manifest and every node, used to move stories between systems. Format:
{
"id": "my_story",
"manifest": {
"id": "my_story",
"title": "...",
"description": "...",
"authorName": "...",
"authorId": "...",
"startNode": "intro",
"language": "en",
"date": "2026-05-01"
},
"nodes": {
"intro": {
"id": "intro",
"text": "...",
"intents": [/* ... */]
},
"kitchen": { /* ... */ },
"end_good": { /* ... */ }
}
}
To import: in the editor, click Import and select your .json bundle. The editor validates structure and loads it. You can then edit and publish.
To export: in the editor, with a story open, click Export. You get a bundle file matching the format above.
20. Publishing
Once a story is on disk in stories/<id>/, the engine picks it up:
- Automatically on container boot (every restart).
- On demand via the editor's Publish button.
- On demand via the admin reload endpoint, for operators.
Once loaded, the story has:
- An entry on
/playwith a card and a [ SHARE PAGE ] link. - A static SEO page at
/stories/<id>with full meta tags, OpenGraph, Twitter Card, and JSON-LD CreativeWork schema for crawlers. - An entry in
/sitemap.xml. - A playable link at
/game.html?story=<id>.
21. Recipes
NPC trust counter
// Press intent: lose trust
{
"id": "press_innkeeper",
"action": "text",
"response": "He folds his arms.",
"adjust_state": { "innkeeper_trust": -1 }
}
// Sympathize: gain trust
{
"id": "sympathize_innkeeper",
"action": "text",
"response": "He nods slowly.",
"adjust_state": { "innkeeper_trust": 1 }
}
// Trust-gated intent: confess deepest secret only at trust 3+
{
"id": "innkeeper_confesses_thing",
"requires_state_gte": { "innkeeper_trust": 3 },
"action": "text",
"response": "He looks at the door, then at you. 'Twenty years ago...'"
}
Time pressure with multiple consequences
// On the kitchen node:
"force_transition": [
{ "if_state_gte": { "turn": 40 }, "target": "scene_dawn_breaks" }
]
// On a confession intent that's only available before dawn:
{
"id": "ask_for_truth",
"requires_state_lte": { "turn": 35 },
"action": "text",
"response": "..."
}
Innocent death the player caused
// On the press_npc intent: build strain
"adjust_state": { "npc_strain": 1 }
// On the npc node: redirect to the death scene if strain hits 4
"force_transition": [
{ "if_state_gte": { "npc_strain": 4 }, "target": "scene_npc_collapses" }
]
// scene_npc_collapses then sets a flag the world remembers
"intents": [{
"id": "leave_after_collapse",
"set_state": { "npc_dead": true },
"action": "transition",
"target": "end_too_late"
}]
Multiple endings on a single decision node
"intents": [
{
"id": "pull_lever_a",
"action": "transition",
"target": "end_a"
},
{
"id": "pull_lever_b",
"requires_state_gte": { "knowledge": 2 },
"action": "transition",
"target": "end_b"
},
{
"id": "pull_lever_c",
"requires": ["heirloom"],
"requires_state": { "vow_taken": true },
"action": "transition",
"target": "end_c"
}
]
The same node, three different exits. The deeper exits stay invisible until the player has earned them.
Hidden cross-check that exposes a liar
// NPC A's claim:
{
"id": "ask_npc_a_about_year",
"set_state": { "a_claims_was_in_paris_1916": true },
"action": "text",
"response": "'I was in Paris in 1916.'"
}
// NPC B's flat fact:
{
"id": "ask_npc_b_about_a",
"set_state": { "knows_a_was_in_lyon_1916": true },
"action": "text",
"response": "'A? He was in Lyon all of 1916. I was the one who sent him there.'"
}
// The accusation, only available with both pieces:
{
"id": "confront_a_lying",
"requires_state": {
"a_claims_was_in_paris_1916": true,
"knows_a_was_in_lyon_1916": true
},
"set_state": { "a_exposed": true },
"action": "text",
"response": "'You were in Lyon, not Paris. B told me.'"
}
The player has to do the connecting. The engine just gates on whether they have.
22. Field cheat-sheet
Manifest
id, title, description, authorName, authorId, startNode, language, date
Node
id, text, text_revisit, text_conditionals, force_transition, intents
Intent
id, intent_description, ai_intent_helper, action,
target, item_id, response, text_variants, text_description, visible,
requires, requires_not,
requires_state, requires_not_state,
requires_state_gte, requires_state_lte, requires_state_neq,
set_state, adjust_state, consume, reveals
Conditional & force_transition predicate
if_state, if_not_state, if_state_gte, if_state_lte, target (force_transition only), text (text_conditionals only)
Action types
text, transition, pickup, end_game
If something here is wrong or missing, please open an issue or ping the maintainer. The engine evolves; this page evolves with it.