- Python 81.4%
- JavaScript 14.5%
- CSS 3.8%
- HTML 0.2%
- Dockerfile 0.1%
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
|
||
|---|---|---|
| roughshod | ||
| stable | ||
| tests | ||
| .dockerignore | ||
| .gitignore | ||
| docker-compose.yml | ||
| Dockerfile | ||
| PROTOCOL.md | ||
| pyproject.toml | ||
| README.md | ||
| start.sh | ||
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_modeis 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:
-
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. -
Import your own code relatively. From
game.py,from . import utilorfrom .lib.helper import thing. Fromcharacters/mira.py,from ..npc import NPC(up to game code) orfrom . 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. -
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. -
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. -
An intermediate Character base goes outside
characters/, e.g.npc.py. Everything incharacters/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 = Trueor no ownidalso 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.