Skip to content

Lore Story Engine

Try the interactive example ↓

Runs compiled interactive-fiction programs as server-side sessions. You author a story in the Lore DSL, compile it to JSON, and the server executes it - emitting story events (speak, choose) and blocking until the player answers. Branching, variables, and save points live on the server, so the client stays a renderer.

When to reach for it: visual novels and branching fiction, RPG dialogue trees whose state must not live on the client, scripted onboarding flows, live-ops narrative beats changed without shipping a new build.

When not to: entity lifecycles with guards and transitions belong to FSM; storing universes, actors, and stories without running them is Narrative.

Concepts

  • Only compiled programs run. The interpreter executes a LoreCompilerOutput JSON document, never .lore source. Compile locally with lore compile, or hand the source to Narrative to compile server-side.
  • Lore runs on Narrative at runtime, not just at build time. Every session is backed by a Narrative session, @imported actors resolve to Narrative actor templates, and in-script @event and @award directives write through to Narrative actors.
  • Sessions are in-memory and owned. A session lives in the API process, is keyed to the creating principal, and does not survive a restart. Checkpoints, which live in Redis, do.
  • The play loop is acknowledgement-driven. When the interpreter needs input it emits an event carrying an ackId and blocks until it gets a ready reply quoting that id. A WebSocket streams those events live; POST .../step sends one command and returns the events that follow.
  • Checkpoints are scoped to universe + story + user, not to a session. That is what makes resuming in a fresh session work at all.

Running a story without publishing it

The dev endpoint takes compiled output inline and fabricates the Narrative universe, story, and actor templates it needs - the fastest path from a .lore file to a running session:

lore compile -i docs4-vault.lore -o docs4-vault.json
snug --output json lore dev -o @docs4-vault.json --auto-start
{
  "session_id": "HWqbERgJhJQK",
  "websocket_url": "/ws/lore?session=HWqbERgJhJQK"
}

Each dev session mints a brand-new story id, so its checkpoints are invisible to the next one. Use a published story when you need progress to carry over.

Running a published story

Give Narrative the source and it compiles it into the story's lore_compiled field; Lore then reads that field. Over HTTP:

curl -X POST -H "Authorization: Bearer $SNUG_API_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"universe_id": "BSvXeGcHSGZV", "story_id": "VMfTzXyKdCjU", "auto_start": true}' \
  http://localhost:4000/api/v1/lore/sessions
{
  "status": 200,
  "msg": "OK",
  "data": {
    "session": {
      "id": "EWtGUFrGpqwP",
      "universe_id": "BSvXeGcHSGZV",
      "story_id": "VMfTzXyKdCjU",
      "user_id": "docs-wave",
      "state": "created",
      "created_at": "2026-08-29T12:48:04.772965Z",
      "last_activity_at": "2026-08-29T12:48:04.772965Z"
    },
    "websocket_url": "/ws/lore?session=EWtGUFrGpqwP"
  }
}

The equivalent CLI call is snug lore create -u <universe> -s <story> --auto-start. A story whose source was never saved fails here with 400 compilation_failed ("Story has no compiled lore output"), and an unknown story id with 404 story_not_found.

The step loop

step sends one command and returns everything the interpreter emits before it blocks again. With no --event it sends ready and auto-resolves whichever acknowledgement is outstanding, which is all a straight playthrough needs:

snug --output json lore step EWtGUFrGpqwP
{
  "session_id": "EWtGUFrGpqwP",
  "state": "running",
  "events": [
    { "event": "did_load", "data": {} },
    { "event": "will_start", "data": {} },
    { "event": "did_start", "data": {} },
    {
      "event": "speak",
      "data": {
        "ackId": "20ec6912-...",
        "character": "narrator",
        "text": "You wake in a cold stone corridor."
      },
      "ackId": "20ec6912-..."
    }
  ],
  "awaiting_input": true
}

awaiting_input means the interpreter is blocked. Answering a choose sends the index of the option you want, under the key the interpreter reads:

snug --output json lore step EWtGUFrGpqwP -d '{"choice":{"value":1}}'

Keep stepping until state is completed, which arrives alongside will_stop and did_stop; stepping a session that is not running returns 409 session_not_running. Variables can be seeded at creation - adding -i '{"difficulty":"nightmare"}' to snug lore dev rendered a ${difficulty} interpolation as Difficulty is nightmare.

WebSocket

GET /ws/lore?session={id} upgrades to a raw stream of the same {event, data, ackId} messages and accepts the same replies, with the token in the Authorization header or a token query parameter. Every failure - missing session, bad token, unknown session, someone else's session - is caught during the handshake, so a rejected connection never reaches 101 at all rather than opening and then closing. The event vocabulary is in the API reference.

Behaviors and gotchas

  • --auto-start is effectively mandatory outside WebSocket. The REST command endpoint rejects any session that is not already running, and start is precisely the command that would make it running - so sending start over REST always returns 409 session_not_running. A session created without auto_start can only be started by connecting a WebSocket.
  • Finishing a story does not free its slot. The per-user cap of five counts every session the process holds, completed ones included; the sixth create returns 429 session_limit_exceeded. Reclaim slots with snug lore delete <id> --force.
  • Another user's session reads as absent, not forbidden. Ownership failures return 404 session_not_found rather than 403, and the session is simply missing from their lore list.
  • Checkpoint names are not symmetric between the two surfaces. This bites everyone once: see Checkpoints and resuming.

Limits and configuration

Five concurrent sessions per user, 1000 server-wide, and a one-hour idle timeout swept every minute. Unlike most services these are compile-time defaults only - Lore reads no environment variables at all.

Reference

Interactive example

Run branching stories as sessions that wait for player choices. Keep dialogue, variables, and checkpoints with the story as it advances.

An illustrative example that runs in your browser. No account or API calls required.