ServicesLore Story Engine

Lore Story Engine

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. Every example on this page was executed against a live server.

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, as the Lore CONFIG reference spells out.

Reference

On this page