LLM powered narrative game engine and context mixer
  • Python 81.4%
  • JavaScript 14.5%
  • CSS 3.8%
  • HTML 0.2%
  • Dockerfile 0.1%
Find a file
DalenPlanestrider ea9e313666 v1.9 tool audiences, scene flattening, tools of fate, setup hook
Changes
* Tool visibility is now per call. Tool.render (RenderMode) is replaced
  by Tool.audience (self / all / list of character ids) and
  Tool.marker. The audience is stamped on each call at landing under
  metadata.audience. In other characters' context a hidden call shows
  up as a null hidden_action call, or is left out entirely when marker
  is off. Other characters' thinking blocks are never included. Player
  use_tool calls get the same stamping, and get_tools reports
  audience/marker instead of render
* New Game.setup() hook. It runs on both new and load, before start()
  and load(), for runtime-only objects built from code. Wiki, Clock
  and TimePackets docs now wire through setup() instead of doubling
  up in start() and load()
* Scene casts are no longer add-only. set_scene can drop a member who
  has no turn anywhere in the scene's tree (every take and branch
  counts), and their POV summary goes with them. A refused set_scene
  changes nothing
* call_llm takes a retool() callback that reloads the toolset after
  each tool round, so a tool that changes what get_tools offers takes
  effect on the next call instead of the next turn. Wired into the job
  runner, shadow_step, speak and rag_write
* Tool schemas now list parameter defaults (except None and
  non-JSON-safe values)
* Stable archival moved out of Game into Host (new archive module), and
  archive is now a host packet. The save zip and the game zip are
  posted first, and notes/tags are posted last against the returned
  entry id instead of riding along as URL-encoded query params
* Assembly clone is now a deep copy, fixing later stages mutating the
  live tree's blocks in place
* Scene summaries are boxed in start/end separators so models stop
  merging them into the next scene's opening. Restyle them with
  AssemblyInfo.summary_formatter
* Shadow render depth is configurable per speaker: character data,
  then a shadow_depth setting, then 1
* Rewrote the monologue prompt as first-person feelings, and renamed
  its shadow tag from internal_monologue to character_feelings. The
  mechanics prompt no longer asks for a preamble, a forced action or
  a closing summary
* On mobile, Enter is a newline and sending is done with the button
* Text settings given None now read as an empty string
* DnD stat descriptions say "creature" instead of "mortal"

Additions
* blank packet and /blank command: land an empty user or assistant
  turn for a character, to fill in with edit
* scene_flatten packet and /flatten command: commit a scene to its
  active thread, deleting every off-thread take and branch. World
  state only referenced by the deleted branches drops out of the next
  save
* /cast and /uncast commands, and a "Leave scene" button on the cast
  panel for members who haven't spoken
* speaker_names setting: other characters' turns are prefixed with
  "Name: ", and a speaker echoing its own name gets it stripped, both
  while streaming and in the landed text (new labels module)
* Tools of fate in gamelib.extratools. Each one gives the model an
  answer it has to interpret:
  * Tarot: full 78-card deck, reversals, rare extra card
  * Oracle: yes/no with "and"/"but" degrees weighted by likelihood
  * TrickstersVoice: context-free LLM side call that makes up an answer
  * GoddessVoice: the author answers in good faith
  * SeersMadness: passage-grammar visions steered by a mood vector
    stored per character that drifts a little with each use
  * LuckyCoin: fair flip with rare comedic mishaps
  * Bibliomancy: random line from the story so far, respecting views
  * ToolOfFate: one-time tool that permanently assigns the character a
    random fate tool, swapped in mid-turn via retool
2026-10-08 03:52:58 +00:00
roughshod v1.9 tool audiences, scene flattening, tools of fate, setup hook 2026-10-08 03:52:58 +00:00
stable v1.9 tool audiences, scene flattening, tools of fate, setup hook 2026-10-08 03:52:58 +00:00
tests v1.9 tool audiences, scene flattening, tools of fate, setup hook 2026-10-08 03:52:58 +00:00
.dockerignore v1.7 string step enum refactor, stable service, docker 2026-08-28 21:42:35 -06:00
.gitignore v1.9 tool audiences, scene flattening, tools of fate, setup hook 2026-10-08 03:52:58 +00:00
docker-compose.yml v1.8 new save format, cost tracking, UI tweaks 2026-08-29 20:49:43 -06:00
Dockerfile v1.8 new save format, cost tracking, UI tweaks 2026-08-29 20:49:43 -06:00
PROTOCOL.md v1.9 tool audiences, scene flattening, tools of fate, setup hook 2026-10-08 03:52:58 +00:00
pyproject.toml v1.7 string step enum refactor, stable service, docker 2026-08-28 21:42:35 -06:00
README.md v1.9 tool audiences, scene flattening, tools of fate, setup hook 2026-10-08 03:52:58 +00:00
start.sh v1.1 turn hooks, snapshot, quoting colors, more 2026-08-23 14:10:15 -06:00

Roughshod

A single-user engine for LLM roleplay and interactive fiction where the "lorebook" is Python you write, and everything you'd want to tweak mid-session gets a UI for free.

Two analogies, both load-bearing:

  • A DAW for narrative context. Scenes are tracks. Each scene's display_mode is its fader - full signal, gated by presence ("you weren't there"), mixed down to a summary, a per-character POV recollection, or silence. You compose what each model call hears, live, from the mixer. Summaries are bounced stems: rendered once, mixed in cheap.
  • SillyTavern, but the lorebook is Python. Instead of a bazillion abstruse config values (keys, priorities, insertion depths, trigger regexes), a world entry is a method that returns a string, computed however you like - consult character mood, world state, the phase of an in-fiction moon. And the knobs a world does want to expose declare themselves once in code and become UI widgets automatically.

The user and the author are the same person. A game folder isn't a shipped product with rules to protect from the player; it's your workspace. The wire protocol isn't a player-facing API to be minimized; it's the control surface of your own instrument.

Core principles

  • Facts are owned, text is derived. Character facts live on the Character, world facts on the Game; prose (descriptions, prompt sections) is rendered at assembly time by hooks and never written back into state. Nothing ever does character.description = <llm output>. Deliberate exception: scene summaries are cached derivations - expensive to render, snapshot semantics by intent.
  • History is never rewritten by the system. Turns are stamped at landing (display name, portrait) and keep those stamps forever. The user edits freely - they're the author; the rule binds the machine.
  • Definitions are code, values are facts. Settings, characters, prompts, pipelines: shape lives in Python, saves carry only values. Edit the code, reload, keep your data.
  • Dumb data, plain functions. No task graphs, no god objects. Pipelines are lists of functions sharing a bag of properties. If a behavior can be a plain method on Game, it is.

Layout

roughshod/           The engine package - the import target for worlds.
  __init__.py        Re-exports the authoring surface: Game, Setting,
                     Character, Scene, Turn, TurnContainer, Role,
                     DisplayMode, Message, LLMSpec, Tool, ToolEnv, ...
                     A world's game.py usually needs only
                     `from roughshod import ...`.
  core.py            The tree: Context > Scene > TurnContainer > Turn(s
                     of Messages). Branching takes, shadows (out-of-band
                     step results), DisplayMode faders. Flat, id-linked
                     serialization. Messages carry
                     provider_allows_merge: a provider veto on being
                     restructured, since replay rules are the one thing
                     the assembler can't infer from block shapes.
  character.py       Concrete Character: one constructor call for simple
                     cast, subclass for coded ones (hooks: get_snippet,
                     get_portrait, llm_for, get_tools, has_capability).
                     Data-only characters fill arbitrary snippet slots
                     via data['snippets']. The authored cast lives in
                     <game_dir>/characters/, one file per guy, loaded by
                     game.register_cast(): .json is a character card
                     (plain Character, facts only); .py is a coded guy
                     - a subclass is a singular guy, not a type of guy,
                     self-described by class-attribute facts (id =
                     'mira', data = {...}) and instantiated no-arg.
                     Saves override class facts per-field on load, so
                     facts added to a class reach old saves. Utility
                     base classes mark _abstract = True.
  assembler.py       Context -> LLM messages. Three stage lists (tree
                     walk, turn transform, message transform) of plain
                     functions; games nudge by inserting stages, replace
                     by overriding the list.
  prompts.py         Task -> system template + end synthetic. Plain
                     dicts of {{var}} text; per-world overrides via
                     '{task}.prompt'.
  generate.py        The pipeline: one CoT to produce one turn - the
                     whole call-and-resolve span, tool traffic included.
                     Steps (monologue -> speak) self-gate on character
                     capabilities; atoms assemble()/call_llm() take
                     plain args. Shadows are the scratchpad.
  llm.py             LLMSpec (declarative, keys from env) + streaming
                     providers. OpenAI-compatible (three class attrs
                     each): openai, openrouter, deepseek, moonshot,
                     zai. Native: anthropic (Messages API - the block
                     model is already Anthropic-shaped, so format()
                     only hoists system out and prunes stale thinking)
                     and google (Gemini: role/parts contents, tool
                     results keyed by name, filter reasons kept in
                     metadata). Go native when a provider's stop
                     reasons matter; compat flattens them.
  tools.py           Tool: subclass with a typed execute(), schema
                     derived from hints, docstring is the description.
                     `audience` says which characters see a call
                     (private by default); assembly hides the rest.
  host.py            The shell before and around a Game: the one pump
                     (packets in, exactly-one-terminal-reply out,
                     defer/cancel), lifecycle packets (new/load/save),
                     world discovery. Also Packet, ProtocolError,
                     Cancelled, Bus.
  game.py            Game (lifecycle, characters, settings, scenes,
                     LLM registry, all on_<packet> handlers) + Setting.
                     The file worlds subclass from.
  web/               Transport only: Flask + websocket, Hub fan-out,
                     auth, /portrait (+ templates/, static/). All logic
                     lives behind host.Bus.
  gamelib/           Optional, opt-in toolkit for worlds. The engine
                     never imports it; worlds import and register what
                     they want.
games/               One folder per world: manifest.json + game.py
                     (+ characters/, portraits/). User workspace, not
                     package code.
stable/              The long-term archive: an ingest server (post
                     saves + notes, dedup by content hash, store
                     game-code snapshots) and an analysis runner
                     (modular facts over the corpus, JSON-tier or
                     game-tier). Imports nothing from roughshod at
                     ingest; analysis can construct Games headlessly.
PROTOCOL.md          The wire contract. A frontend needs nothing else.

The data model, top down

A Context is an ordered list of Scenes. A Scene holds a tree of TurnContainers - retries branch, take switches the active path - but nobody downstream deals with the tree: the client gets a flat thread (branchiness surfaces only as take counters), the LLM gets whatever the assembler mixes down, saves get a flat node table. Three projections, one truth, no reverse parsing.

Containers also carry shadows: out-of-band results from pipeline side-steps (internal monologue, plans, RAG pulls) that later steps can hear but that never become chat messages.

A Game owns the cast (characters), world facts (state), the player's identity (player_id), user knobs (settings), the model routing table (llms: (task, character_id) -> LLMSpec, wildcard fallbacks), and the Context. Three lifecycle hooks:

new:   construct (empty)     -> setup() -> start()
load:  construct (save body) -> setup() -> load()

setup() builds runtime-only objects from code (a WikiSet, a Clock and its handlers) and runs on both paths, before the cast or seeded facts are guaranteed to exist. start() seeds facts, once. load() fixes up facts from a save.

Generation is a pipeline: a list of plain functions run in order, each doing its own gather → assemble → LLM call → mutate. Steps self-gate on the speaking character's capabilities, so one standard list serves casts of mixed complexity. A retry re-runs the whole chain - fresh thoughts, not reheated ones.

Writing a world

A world is a folder in games/:

games/<identifier>/
  manifest.json        {"name": ..., "description": ...}, readable at
                       runtime as self.manifest (add your own keys)
  game.py              exactly one Game subclass
  characters/*.py      optional coded Character subclasses (auto-found)
  characters/_*.py      skipped by discovery - shared bases and helpers
  portraits/*.png      <character_id>.png probed automatically
  anything else.py     your own modules, imported relatively

Imports

A world folder is a Python package, named after the folder. When games/tavern/ loads, it is registered as the top-level package tavern, so its own code is importable - no __init__.py, no sys.path fiddling, and independent of how the app was launched (python -m roughshod, python roughshod, or the roughshod script).

Five rules cover everything:

  1. Import the engine absolutely. from roughshod import Game, Character, from roughshod.gamelib.dnd import DnDSkillCheckTool. The dependency arrow only points one way: worlds import the engine, never the reverse.

  2. Import your own code relatively. From game.py, from . import util or from .lib.helper import thing. From characters/mira.py, from ..npc import NPC (up to game code) or from . import _shared (sideways). Relative imports are the recommended form because they keep a world copy-paste portable - rename the folder and the code still works.

  3. Absolute self-imports work too, if you prefer them: import tavern.util. Same module objects as the relative spelling. The cost is that the folder name is now baked into your source.

  4. Put shared code wherever you like. It's a real package, so there's no privileged location: util.py, lib/helper.py, rules/combat.py. Subfolders need no __init__.py.

  5. An intermediate Character base goes outside characters/, e.g. npc.py. Everything in characters/ is imported and scanned, so the folder reads best as one-file-per-guy. If you'd rather keep a base next to its guys, name it _base.py - leading-underscore files are skipped by discovery. (A class with _abstract = True or no own id also contributes a type without becoming a guy.)

Shared bases work as you'd expect: two characters doing from ..npc import NPC get the same class object, so isinstance behaves.

Worlds cannot import each other - only the loaded world is registered, so siblings simply aren't importable. Code you want to share between worlds belongs in gamelib.

One constraint follows from folder-name-is-package-name: the folder must be a valid Python identifier. my_world is fine; my-world and 2spooky are rejected. Avoid names that collide with real modules (test, json, email) - the engine warns, and while that world is loaded its own code can't reach the shadowed module.

Live editing and bytecode

Worlds are re-imported from disk on every new, so you edit the Python and start a new session - no server restart.

By default Python caches compiled bytecode in __pycache__, which is faster but validates caches by (mtime, size). An edit preserving both - a same-length tweak inside the same second - can load the old code. If you're iterating hard and want reloads you can trust absolutely, opt out in the manifest:

{"name": "The Rusty Flagon", "bytecode": false}

That disables bytecode writes for the world's own modules and clears any __pycache__ already in its folder. Default is true.

The included games/tavern/ is the working reference - a data-only character, a coded one with mood-derived prose and portraits, settings that flow into prompts, a dice tool, and a seeded LLM default. The short version:

from roughshod import Character, Game, LLMSpec, Scene, Setting

class TavernGame(Game):
    SETTINGS = {
        'tone': Setting('dropdown', 'Story tone',
                        options=['cozy', 'dramatic', 'absurd'],
                        default_value='cozy'),
    }

    def start(self):
        self.register_cast()          # characters/*.json (+ .py behavior)
        self.player_id = 'traveler'
        self.llms[('', '')] = LLMSpec.from_str('openrouter:some/model')
        self.context.scenes.append(Scene(scene_name='the common room',
                                         character_ids=['bram', 'traveler']))

    def assembly_data(self, task, character_id=''):
        return {'setting': f'Night {self.state.get("night", 1)}, raining.',
                'instructions': f'Keep the tone {self.setting("tone")}.'}

Everything is optional past start() (and setup() once the world has runtime objects to wire). Add hooks as the world needs them: describe() for last-word control over character prose, get_tools() for world tools, generate_steps() to reshape the pipeline, player_changed()/setting_changed() to react to the user, turn_landed() for per-turn bookkeeping (fires on every landing - player say, generation, tool span, retake - with an action tag to filter by: advance a clock on 'speak', snapshot unconditionally), on_<anything> to invent new packets, TICK_INTERVAL + tick() for a coarse idle clock (serialized with packet handling; delta-time, not a fixed step).

A few SETTINGS keys are read by the engine when a world declares them: shadow_depth (how far back a speaker's own shadows render), auto_speakers (max AI turns chained after a player line), summarize_words (summary length), and speaker_names (boolean: prefix other characters' turns with Name: and strip the speaker's echo of its own name from its replies).

Run

pip install -e .
APP_PASSWORD=yourpassword OPENROUTER_KEY=sk-... python -m roughshod
# http://127.0.0.1:8000  (default password: letmein)

Provider keys come from the environment (OPENAI_KEY, OPENROUTER_KEY, DEEPSEEK_KEY, MOONSHOT_KEY, ZAI_KEY, ANTHROPIC_KEY, GOOGLE_KEY) - never from saves or the wire. (GOOGLE_KEY is AI Studio; Vertex uses separate credentials and would want its own provider entry.) GAMES_DIR / SAVES_DIR override the default ./games and ./saves.

Archiving

Point the engine at a stable server and archive annotated saves for long-term analysis:

ARCHIVE_URL=http://nas:8100 ARCHIVE_TOKEN=... python -m roughshod
STABLE_DIR=/mnt/nas/stable STABLE_TOKEN=... python -m stable serve

From the client: {'type': 'archive', 'notes': '...', 'tags': [...]}. The engine posts the save zip (the same bytes a local save writes), then a zip of the game folder if the server doesn't already hold that code (checked by content hash), then notes/tags against the returned entry id. Analysis runs offline over the corpus:

python -m stable analyze --games-dir ./games

Facts are plain decorated functions in stable/facts/, discovered automatically. JSON-tier facts read the save as data; game-tier facts get a constructed Game. Results land in stable-data/analysis/.

Test

python -m pytest

Fully synchronous: threads and queues, no asyncio. One user, one pump, one operation at a time - packets arriving mid-operation defer, cancel unwinds everything. The full wire contract is in PROTOCOL.md.