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:

  1. The engine renders the current node's text.
  2. The player types a sentence in their own words.
  3. The engine asks the AI which of the node's currently-available intents best matches that sentence.
  4. The matched intent's effects (state changes, item moves, transitions) apply.
  5. 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:

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:

  1. 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.)
  2. Engine selects the text body in this priority: text_conditionals first match wins; otherwise text_revisit if visited before; otherwise text.
  3. 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:

  1. First matching text_conditionals entry, if any.
  2. Otherwise text_revisit, if the player has been here before.
  3. 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:

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.

Loop watch. Don't force-transition into a node whose own force_transition fires the same condition. The engine doesn't recurse intentionally; the result is one redirect, then the player sees whatever node ends up rendered.

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.

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:

  1. Filters the current node's intents by all gates → list of currently-available intents.
  2. Sends those intents (id and ai_intent_helper) plus the player input to an AI provider.
  3. The AI returns the ID of the best-matching intent, or "unknown" if nothing fits.
  4. The engine caches that input → intent_id mapping in Redis. Subsequent identical inputs skip the AI.

What this means for authors:

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:

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.

Hand-writing without the editor. If you'd rather work in your editor of choice, write the manifest and node files into a folder per the structure shown in section 3, then either: (a) zip the folder, give it to the project owner with deployment access; or (b) build the bundle JSON above and import it through the editor's Import button. Option (b) is the user-facing path.

20. Publishing

Once a story is on disk in stories/<id>/, the engine picks it up:

Once loaded, the story has:

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.