Skip to main content

Offline voice agent that role-plays NPCs at the tabletop RPG game table

Project description

npc โ€” an offline AI NPC for your game table

PyPI Vibe Coded Coded with Claude Code License: AGPL v3

Hold a button, talk to an NPC, and it talks back โ€” in character, out loud, with no cloud in sight.

Demo: a spoken exchange with an NPC, including a GM-gated secret reveal

๐Ÿ”Š Hear the voice: docs/samples/vess-greeting.mp3 ยท ๐Ÿ–ฅ๏ธ The table overlay ยท ๐ŸŽฒ Try it now: uv run npc run examples/rusty-lantern โ€” see the gallery

npc is a terminal program for game masters running tabletop RPGs like Numenera. You describe an NPC in a markdown file, start a session, and hand your players a voice: hold the push-to-talk key, speak as a player (in English or Swedish), and the NPC answers in spoken British English, staying true to its personality, knowledge, and secrets. Between sessions the NPC keeps a logbook, so it remembers what your party did last time.

Everything runs locally on your own machine. After the one-time model downloads, no internet connection is used โ€” your campaign never leaves the table.

How it works

flowchart LR
    A["๐Ÿ”˜ Hold key<br>(spacebar / USB button)"] --> B["๐ŸŽค Record<br>sounddevice"]
    B --> C["๐Ÿ“ Speech-to-text<br>faster-whisper<br>(sv/en auto-detect)"]
    C --> D["๐Ÿง  Local LLM<br>Ollama<br>+ character sheet<br>+ logbook"]
    D --> E["๐Ÿ”Š Text-to-speech<br>Piper (Alba, en_GB)"]

Two channels, one terminal:

Input Meaning
Voice (hold the push-to-talk key) Always in-character player dialogue โ€” the NPC hears a player speaking to it
Typed text at the gm> prompt Always an out-of-character instruction to the underlying LLM โ€” "be more hostile", "you just saw them steal your idol"

This separation is absolute by design: the LLM can never mistake table chatter for stage direction, and your players can never "prompt-inject" the NPC by talking to it.

Requirements

  • Linux (X11 or Wayland โ€” push-to-talk works on both)
  • Python โ‰ฅ 3.11 and uv
  • A local LLM server: Ollama (default) โ€” or any app serving an OpenAI-compatible API, like Jan, LM Studio, llama.cpp's server, or vLLM
  • A microphone and speakers
  • Optional but recommended: an NVIDIA GPU (a 7Bโ€“14B model + GPU whisper make responses fast enough for live play; CPU-only works with smaller models)

Disk footprint after setup: roughly 5โ€“6 GB (LLM โ‰ˆ 4.5 GB, whisper-small โ‰ˆ 0.5 GB, Piper voice โ‰ˆ 60 MB).

Installation

1. System packages (Ubuntu/Debian)

sudo apt install libportaudio2        # microphone & speaker access for Python
sudo usermod -aG input $USER          # lets npc read key press/release events

Log out and back in after the usermod โ€” group changes only apply to new logins. (Why: terminals never report key releases, so hold-to-talk reads the keyboard device directly via evdev, which requires membership in the input group. No root needed.)

2. A local LLM server

Option A โ€” Ollama (default, fully terminal-driven):

curl -fsSL https://ollama.com/install.sh | sh
ollama pull qwen2.5:7b-instruct       # the default model (~4.5 GB)

Option B โ€” an LLM app you already run (Jan, LM Studio, llama.cpp, vLLM, โ€ฆ): no extra software needed. Enable the app's local API server, load a model, and point config.toml at it โ€” see Using Jan, LM Studio, or another LLM app.

3. This project

The easiest way is from PyPI โ€” the package is ttrpg-npc, the installed command is npc:

uv tool install ttrpg-npc             # or: pipx install ttrpg-npc
uv tool install "ttrpg-npc[cuda]"     # with NVIDIA GPU (adds CUDA libs for whisper)

That puts npc on your PATH (~/.local/bin); the uv run prefix in the examples below becomes optional.

Or from source, if you want to hack on it:

git clone https://github.com/seetee/npc.git
cd npc
uv sync                               # CPU-only
uv sync --extra cuda                  # with NVIDIA GPU

After a source install there is no bare npc command โ€” the program lives inside the project's virtualenv, so you invoke it through uv, from the project directory:

uv run npc <command> โ€ฆ

To get a direct npc command from a clone too, install it as an editable uv tool: uv tool install --editable ~/path/to/npc โ€” it then always runs the current code in your clone; pulling new commits is enough, no reinstall.

4. Check everything

uv run npc init campaigns/mygame      # scaffold a campaign
uv run npc doctor campaigns/mygame    # verify + download speech models

doctor checks every link in the chain โ€” Ollama running, model pulled, whisper cached, Piper voice downloaded, audio devices present, input permissions โ€” and prints a copy-pasteable fix for anything that fails. Run it until everything says PASS. The whisper model and Piper voice download automatically on the first run; after that you're fully offline.

npc doctor --fix goes one step further and offers to run the safe fixes for you (pull the Ollama model, download the Piper voice) after a [y/N] prompt. Anything needing sudo stays copy-paste-only.

Creating your NPC

npc init campaigns/mygame walks you through it: press Enter to start with the ready-to-play example NPC (Vess), or type a name and a one-line concept to get a personalized skeleton to flesh out. Either way you end up with a campaign directory of plain markdown โ€” edit freely, no special syntax:

campaigns/mygame/
โ”œโ”€โ”€ character.md    # WHO the NPC is: personality, speech style, knowledge,
โ”‚                   # secrets, hard rules. First "# heading" = the NPC's name.
โ”œโ”€โ”€ adventure.md    # your campaign background, from the GM's point of view
โ”œโ”€โ”€ secrets.md      # GM-gated clues: the NPC asks YOU before revealing these
โ”œโ”€โ”€ lore/           # reference docs (.txt/.md/.pdf) the NPC knows deeply
โ”œโ”€โ”€ logbook.md      # session summaries โ€” written by the LLM, editable by you
โ”œโ”€โ”€ config.toml     # models, voice, hotkey (all optional, defaults included)
โ””โ”€โ”€ sessions/       # raw per-session transcripts, appended turn by turn

The scaffolded character.md contains a complete example NPC (Vess, a wary Vault Priest) showing the intended level of detail. The Secrets and Hard rules sections are where you control what the NPC will and won't reveal.

Gated secrets: the NPC checks with you first

character.md secrets are soft โ€” the NPC knows them and is told to hold back, but a persistent table can pry them loose. Clues in secrets.md are locked: the NPC is shown only a one-line hint per secret, never the content, so it cannot leak details it has never been given โ€” no matter how hard the players push.

When the conversation touches a locked topic, the NPC stalls in character ("Hm. Give me a momentโ€ฆ") and a prompt appears at your gm> console:

โš‘ Vess asks to reveal (teleporter-key) โ€” the location of a working teleporter key
   player: "What's hidden under the altar?"
   โ†’ /yes [note] ยท /no [note] โ€” stays pending until you decide

Take your time โ€” call for a die roll, check your notes; the request stays pending while play continues. Then answer, optionally steering the reveal: /yes but only vaguely, she's still frightened or /no she lies and blames the raiders. On /yes the secret's full text is handed to the NPC and it shares the clue in its own words; on /no the topic stays locked for the rest of the session; /later just dismisses the request (for a misfire, or "not yet") so it can come up again. /secrets lists each secret's status, and /reveal <id> unlocks one proactively (the NPC brings it up at the next natural moment). Reveals are written back into secrets.md, so they persist across sessions.

Each secret has a mode: hesitate (default โ€” the NPC pauses audibly, which players will notice) or deflect (it brushes the question off as if it knew nothing, and can "change its mind" if you approve). Write each secret's one-line hint: in the words players are likely to use โ€” the hint is the only thing the NPC can match a question against, since it never sees the secret itself. The scaffolded secrets.md documents the format; multi-NPC campaigns use one file per character in secrets/<name>.md. Nothing about a secret โ€” not even its id โ€” is ever sent to the table overlay.

Encyclopedic knowledge (lore files)

Give an NPC deep, reliable knowledge of a topic by dropping reference documents into lore/ โ€” .txt, .md, or .pdf. The contents are injected into the NPC's prompt as established fact: it relies on them when answering and admits ignorance in character when they don't cover something. Files at the root of lore/ belong to the campaign's character.md NPC; in multi-NPC campaigns each character reads lore/<name>/ โ€” knowledge is as strictly per-NPC as memory is.

Mind the context window: documents share the prompt with everything else, so attaching more than a page or two means raising num_ctx under [llm] in config.toml (e.g. num_ctx = 16384 โ€” roughly 8โ€“10k words of lore on a 12 GB GPU with a 7B model). Don't guess: npc doctor measures your actual prompt and prints the value to set, and the session warns you once if the prompt outgrows the window. PDFs are extracted as text โ€” fine for prose, unreliable for two-column rulebooks and tables; doctor flags PDFs that extract suspiciously little (scanned pages), and converting to .txt is always the safer choice.

Multiple NPCs

Drop more character sheets into a characters/ directory โ€” each .md file is an NPC, named by its first # heading โ€” and switch at the table with /npc <name> (/npc alone lists everyone). Each NPC keeps separate memory: their own conversation, their own standing GM notes, and their own logbook (logbooks/<file>.md), so a secret told to one NPC never reaches another โ€” not even across sessions. Give each their own voice in config.toml:

[tts.voices]
korval = "en_GB-northern_english_male-medium"   # characters/korval.md

npc doctor --fix offers to download any mapped voice that's missing. The original character.md keeps working unchanged alongside (or instead of) characters/.

Example campaigns

Two complete campaigns ship in examples/ โ€” play them as-is, or copy one as a starting point:

  • The Rusty Lantern โ€” a classic-fantasy inn. Mera Vex knows everyone on the Saltmarsh road, waters the ale of the rude, and holds two gated secrets about the old innkeeper's death. uv run npc run examples/rusty-lantern
  • The Glass Monolith โ€” a far-future multi-NPC campaign: the wary priest Vess (with lore and secrets) and Tokk the scavenger, showing /npc switching and per-character voices. uv run npc run examples/glass-monolith

Playing a session

uv run npc run campaigns/mygame
At the gm> prompt Effect
hold spacebar, speak, release player speaks to the NPC; it answers out loud
press while the NPC is talking interrupts the reply and starts recording (walkie-talkie style)
type anything out-of-character instruction to the LLM
/say Have you seen the raiders? typed in-character player line (no mic needed)
/npc korval switch which NPC you're talking to (/npc lists them)
/yes only the rough direction / /no answer a pending secret-reveal request, with an optional steer
/later dismiss a reveal request without deciding (it can come up again)
/secrets list the active NPC's gated secrets and their status
/reveal teleporter-key unlock a secret without waiting to be asked
/save write the session summary to the logbook now
/reload re-read character.md, adventure.md, config.toml
/status current state, model, session number
/end summarize the session into the logbook and exit
/quit exit without saving a summary

Ending with /end is what gives the NPC memory: the LLM distills the session's transcript into a dated logbook entry (location, the NPC's attitude, highlights, open threads), and the most recent entries are fed back to it next session. The logbook also auto-checkpoints every 20 player turns, so a crash costs you nothing.

Prefer tapping to holding? Set mode = "tap" under [hotkey] in config.toml: tap once to start talking and a pause (1.2 s of silence by default, tunable via [stt] vad_silence_seconds) sends the recording; a second tap sends it immediately.

Two optional flags on npc run:

  • --timings prints per-stage latency after each reply ([turn 4.9s: stt 0.8s | llm 2.1s (first token 0.31s) | speak 3.2s]); /status always shows the last/average turn time.
  • --overlay (or [overlay] enabled = true in config.toml) serves a live table display at http://127.0.0.1:8765 โ€” add it as an OBS browser source or open it in a browser on the same machine (e.g. a second monitor facing the players). It shows the NPC's name, a state light, the last player line, and the reply streaming in as it's spoken.

Overlay on a tablet (LAN)

By default the overlay listens on this machine only. To put it on a tablet or phone on the table's Wi-Fi, opt in explicitly in config.toml:

[overlay]
enabled = true
listen = "0.0.0.0"     # or your machine's LAN IP

npc run then prints the exact URL to type on the tablet, plus a warning. Know what you're opening: the stream is unauthenticated and unencrypted, so anyone on that network can watch it. In LAN mode only play events are broadcast โ€” the player's line, the NPC's reply, state changes. GM notes, /status, errors, and everything about secrets (even their existence) never leave the machine, on any setting.

Tell your table: everything said to the NPC is transcribed and stored locally in sessions/, and summarized into the logbook. It never leaves the machine โ€” but recording people's words is still something your players should know about and be okay with.

Choosing and changing models

config.toml is scaffolded with every setting present as a comment showing its default:

[llm]
# backend = "ollama"                # or "openai" for Jan, LM Studio, llama.cpp, โ€ฆ
# model = "qwen2.5:7b-instruct"     # Ollama tag, or the model name your app lists
# host = "http://localhost:11434"
[stt]
# model = "small"                   # whisper size: tiny/base/small/medium/large-v3
# language = "auto"                 # auto-detects Swedish and English
[tts]
# voice = "en_GB-alba-medium"       # Piper voice (Alba, British English)

Uncomment and edit to change. The LLM model can be swapped mid-session: edit config.toml, type /reload, and the next reply comes from the new model (handy if an NPC turns out to need more brainpower โ€” or less). Whisper and Piper models are loaded into memory at startup, so changing those takes effect on the next npc run; /reload will tell you when a restart is needed.

Rough model guidance:

Hardware LLM Whisper
CPU only qwen2.5:3b-instruct, llama3.2:3b base or small
8 GB VRAM qwen2.5:7b-instruct (default), llama3.1:8b small
12+ GB VRAM qwen2.5:14b-instruct, mistral-nemo small or medium

Using Jan, LM Studio, or another LLM app

If a machine already runs an LLM app, npc can use it instead of Ollama โ€” anything that serves the standard OpenAI-compatible API works. In the app, enable its local API server and note the model name it exposes, then:

[llm]
backend = "openai"                     # aliases: jan, lmstudio, llamacpp, vllm
host = "http://localhost:1337"         # Jan's default; LM Studio uses :1234
model = "qwen2.5-7b-instruct"          # exactly as the app lists it

(/v1 is appended automatically if you leave it off.) npc doctor will show which models the server actually offers if the configured name doesn't match. The mid-session /reload model swap works the same as with Ollama.

If the server wants an API key, set api_key under [llm] โ€” or better, export NPC_LLM_API_KEY=โ€ฆ, which overrides the file so the key never sits in config.toml in plaintext.

Languages

Players can speak Swedish or English โ€” whisper auto-detects the language per utterance. The NPC always answers in English with the Alba voice; this is enforced in its instructions, so a Swedish question gets an English answer. Pin stt.language = "sv" in config.toml if auto-detection ever guesses wrong.

Using a hardware button

Any USB device that emits key events works โ€” a foot pedal, a macro pad, a Shuttle controller. Find it and pin it:

ls /dev/input/by-id/
[hotkey]
key = "KEY_F13"                                  # whatever your button sends
device = "/dev/input/by-id/usb-...-event-kbd"    # pin this exact device
grab = true                                      # exclusive: presses never leak to the terminal

Only enable grab for a dedicated button โ€” grabbing your actual keyboard would swallow all your typing. Without a dedicated button, if holding spacebar leaves stray spaces annoying you, switch key to KEY_RIGHTCTRL or KEY_F12.

Development

uv sync --group dev
uv run pytest                  # unit tests โ€” no audio hardware or Ollama needed
uv run pytest -m integration   # real whisper/piper/ollama smoke tests (auto-skip)
uv run ruff check .

uv run npc say "test" campaigns/mygame            # debug: TTS only
uv run npc transcribe clip.wav campaigns/mygame   # debug: STT only

The full design โ€” pipeline, threads, the event bus, and the five guarantees (offline, per-NPC isolation, unleakable secrets, English lock, crash-safe writes) โ€” is written up in ARCHITECTURE.md. Every stage sits behind a Protocol, so the whole loop is tested with fakes: no GPU, microphone, or LLM server needed for the test suite. See CONTRIBUTING.md for the workflow (including the prompt probe scripts) and CHANGELOG.md for release history.

License

AGPL-3.0-or-later. Piper is GPL-3.0; whisper, Ollama and the models carry their own licenses.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ttrpg_npc-1.0.0.tar.gz (197.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ttrpg_npc-1.0.0-py3-none-any.whl (89.4 kB view details)

Uploaded Python 3

File details

Details for the file ttrpg_npc-1.0.0.tar.gz.

File metadata

  • Download URL: ttrpg_npc-1.0.0.tar.gz
  • Upload date:
  • Size: 197.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"KDE neon","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ttrpg_npc-1.0.0.tar.gz
Algorithm Hash digest
SHA256 714f0335c5ba168bc809c5da5e8ad3d1be08983d6bdbd80adcc50407925b900b
MD5 eee0f26497bca0634116697342f0bc8b
BLAKE2b-256 321d45a62c9b80cebb37ed1766155aec2dc62ac1a5d30b5b4439e557fd8f0d96

See more details on using hashes here.

File details

Details for the file ttrpg_npc-1.0.0-py3-none-any.whl.

File metadata

  • Download URL: ttrpg_npc-1.0.0-py3-none-any.whl
  • Upload date:
  • Size: 89.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: uv/0.11.29 {"installer":{"name":"uv","version":"0.11.29","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"KDE neon","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}

File hashes

Hashes for ttrpg_npc-1.0.0-py3-none-any.whl
Algorithm Hash digest
SHA256 dc09a90349e3c2d0755ef2a9d3d3d3d363a5f4b2dfb17d8d3389a594b0550f45
MD5 37a1566b2555671866932c562132be84
BLAKE2b-256 404698832074600ae5ddb877dfdccb9b816ca01e03aa743cd78609f76b609bb8

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page