Skip to content

Instantly share code, notes, and snippets.

@ubergarm
Last active December 26, 2025 16:34
Show Gist options
  • Select an option

  • Save ubergarm/8913cc3834a9bf423b64556e9d38333d to your computer and use it in GitHub Desktop.

Select an option

Save ubergarm/8913cc3834a9bf423b64556e9d38333d to your computer and use it in GitHub Desktop.
OSR TTRPG Procedural Random Encounter Generator AI Agent Demo
#!/usr/bin/env python3
"""
# Procedural Random Encounter + Ai Slop Demo
* pydantic‑ai Agent with an locally running OpenAI‑compatible server.
* adds observability with OpenTelemetry backend configuration via environment variables
# Installation
# https://docs.astral.sh/uv/getting-started/installation/
$ uv venv ./venv --python 3.13 --python-preference=only-managed
$ source venv/bin/activate
$ uv pip install pydantic-ai openai logfire rich # maybe more? lol
# Local LLMs
# Instructions provided for ik_llama.cpp on huggingface with model tested here:
# https://huggingface.co/ubergarm/GLM-4.7-GGUF#smol-iq1_kt-82442-gib-1976-bpw
# Configure
# Update env vars or hard codes to point at your LLM endpoints
$ python encounter_agent.py
# License
All code is [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/legalcode) with the actual table data licensed as per respective creators linked in comments.
# Demo Video
https://www.youtube.com/watch?v=IFJ9lDFI2mI
# Discussion
https://www.reddit.com/r/osr/comments/1pvlo9y/demo_procedural_generated_random_encounters_with/
# Notes
This is a *very rough* demo cobbled together over the holiday to see what is possible with local running AI LLMs.
Cheers!
-ubergarm
Fri Dec 26 10:33:22 AM EST 2025
"""
from __future__ import annotations
import asyncio
import os
from datetime import datetime
import random
import re
from typing import Any, Optional
from rich.console import Console
from rich.markdown import Markdown
import logfire
from pydantic_ai import Agent, RunContext
from pydantic_ai.models.openai import OpenAIChatModel
from pydantic_ai.providers.openai import OpenAIProvider
from openai import AsyncOpenAI
# ---------------------------------------------------------------------------
# Configuration – environment variables take precedence over hard‑coded defaults
# ---------------------------------------------------------------------------
BASE_URL: str = os.getenv("OPENAI_BASE_URL", "http://127.0.0.1:8080/v1")
MODEL: str = os.getenv("OPENAI_MODEL", "foo/bar")
API_KEY: str = os.getenv("OPENAI_API_KEY", "n/a")
MAX_RETRIES: int = int(os.getenv("OPENAI_MAX_RETRIES", "3")) # optional, defaults to 3
# Config for Observability via OpenTelemetry Backend
# without this it will throw errors, not sure how to put it in try/catch blocks yet
# $ docker run --rm -it -p 4318:4318 --name otel-tui ymtdzzz/otel-tui:latest
os.environ["OTEL_EXPORTER_OTLP_ENDPOINT"] = "http://localhost:4318"
os.environ["LOGFIRE_CONSOLE_MIN_LOG_LEVEL"] = os.getenv("LOGFIRE_CONSOLE_MIN_LOG_LEVEL", "warn")
logfire.configure(send_to_logfire=False)
logfire.instrument_pydantic_ai()
logfire.instrument_httpx(capture_all=True)
# ---------------------------------------------------------------------------
# Initialise the OpenAI client for ik_llama.cpp running locally
# ---------------------------------------------------------------------------
client = AsyncOpenAI(
max_retries=MAX_RETRIES,
base_url=BASE_URL,
api_key=API_KEY,
)
chat_model = OpenAIChatModel(
MODEL,
provider=OpenAIProvider(openai_client=client),
)
# ---------------------------------------------------------------------------
# Initialise pydantic‑ai Agent
# ---------------------------------------------------------------------------
system_prompt = """\
You are an Old School Renaissance (OSR) Game Master (GM) Referee for a grimdark swords and sorcery campaign using the Shadowdark RPG system.
**Core Philosophy & Tone**
- **Grimdark Atmosphere:** The world is bleak, uncaring, and lethal. Life is cheap, and survival is a struggle against corruption, starvation, and ancient evils. Magic is rare, dangerous, and often comes with a terrible cost.
- **Show, Don't Tell:** Never summarize feelings or abstract concepts. Describe the sensory reality of the scene. Instead of saying "The room feels scary," describe "The stench of rotting meat hangs heavy in the cold, stagnant air. Shadows seem to writhe along the damp walls, and the silence is broken only by the rhythmic dripping of black ichor from the ceiling."
- **Player Agency:** The players drive the story. You describe the situation and the consequences of their actions; you do not dictate their actions or their internal thoughts.
- **Rulings Over Rules:** Shadowdark is rules-light. If a situation isn't covered by a specific mechanic, make a logical, consistent ruling that favors the grittiness of the setting. Ask for ability checks (d20) only when there is a risk of failure and a consequence.
**System Mechanics (Shadowdark)**
- **Dice Rolls:** Use a d20 system for attacks and checks. Roll high for success.
- **Combat:** Combat is deadly and round-by-round. Describe the violence viscerally but focus on the impact.
- **Resources:** Track light sources (torches/lanterns) strictly. Darkness is a primary enemy.
- **Ascending Armor Class (AC):** Unarmored targets have AC 10. Higher AC is harder to hit.
**Output Format**
When presenting an encounter, monster, or significant event, you must strictly adhere to the following two-part structure:
1. **Narrative Flavor Text:**
- This must be the first part of your response.
- Use *italics* for the entire narrative section.
- Write this in the second person ("You see..."), addressing the players directly.
- Focus entirely on sensory details: sight, smell, sound, temperature.
- Include no game mechanics, numbers, or GM jargon in this section.
2. **Stat Blocks & Mechanics:**
- Immediately following the flavor text, provide the necessary game data for the GM (and players) to resolve the scene.
- Use Markdown tables or code blocks for readability.
- Include stats like Name, AC, HP, Speed, Attacks, and Special abilities.
**Example Output Structure:**
*The iron door creaks open on rusted hinges, revealing a chamber choked with a thick, unnatural fog. The smell of ozone and burnt hair fills your nostrils. From the mist, a pair of glowing red eyes emerges, followed by the shambling form of a corpse wrapped in tattered, funerary linens. It drags a heavy, rusted axe behind it, scraping the stone floor with a sound that sets your teeth on edge.*
| Name | Wight | AC | 13 (Chain) | HP | 18 |
| :--- | :--- | :--- | :--- | :--- | :--- |
| **Speed** | 20 ft | **Atk** | +3 (1d8+1, axe) | **Align** | Chaotic |
| **Special** | **Undead:** Immune to morale checks. <br> **Life Drain:** On a hit, target takes 1d4 necrotic damage. <br> **Sunlight Sensitivity:** Disadvantage on attacks while in sunlight. |
**Tool Usage**
- Use available random generation tools (dice rollers, encounter tables, loot generators) to create dynamic content.
- Do not pre-plan outcomes. Generate monsters, treasure, and reactions on the fly to simulate the unpredictability of the OSR style.
- If the players enter a new area, use tools to determine the inhabitants and hazards.
**Interaction Guidelines**
- Keep the pacing brisk. Ask the players, "What do you do?" to prompt action.
- Be fair but merciless. If players make poor choices or fail rolls, describe the grim consequences without hesitation.
- Maintain the mystery of the world. Do not reveal the true nature of monsters or magic immediately; let the players discover the dangers through interaction.
"""
agent = Agent(chat_model, system_prompt=system_prompt)
# ---------------------------------------------------------------------------
# Agent tools
# ---------------------------------------------------------------------------
@agent.tool_plain
async def get_datetime() -> str:
"""
Return the current datetime as an ISO‑8601 string.
The tool is exposed to the LLM so it can inject the exact time
into its response when needed.
"""
return datetime.now().isoformat()
@agent.tool_plain
def roll_dice(number: int, face: int) -> str:
"""Roll a number of dice of the given face size and return the total"""
total = 0
for x in range(number):
total += random.randint(1, face)
print(total)
return str(total)
# Data structure for the provided OSE Dungeon Encounter Table
# https://oldschoolessentials.necroticgnome.com/srd/index.php/Dungeon_Encounters
# https://ose.srd.wiki/
# This table provided as per OSE [Open-Gaming License OGL](https://oldschoolessentials.necroticgnome.com/srd/index.php/Open_Game_License)
DUNGEON_TABLE = {
1: {1: ("Acolyte", "1d8"), 2: ("Beetle, Oil", "1d8"), 3: ("Ape, White", "1d6")},
2: {1: ("Bandit", "1d8"), 2: ("Berserker", "1d6"), 3: ("Basic Adventurers", "1d4+4")},
3: {1: ("Beetle, Fire", "1d8"), 2: ("Cat, Mountain Lion", "1d4"), 3: ("Beetle, Tiger", "1d6")},
4: {1: ("Dwarf", "1d6"), 2: ("Elf", "1d4"), 3: ("Bugbear", "2d4")},
5: {1: ("Gnome", "1d6"), 2: ("Ghoul", "1d6"), 3: ("Carcass Crawler", "1d3")},
6: {1: ("Goblin", "2d4"), 2: ("Gnoll", "1d6"), 3: ("Doppelgänger", "1d6")},
7: {1: ("Green Slime", "1d4"), 2: ("Grey Ooze", "1"), 3: ("Driver Ant", "2d4")},
8: {1: ("Halfling", "3d6"), 2: ("Hobgoblin", "1d6"), 3: ("Gargoyle", "1d6")},
9: {1: ("Killer Bee", "1d10"), 2: ("Lizard, Draco", "1d4"), 3: ("Gelatinous Cube", "1")},
10: {1: ("Kobold", "4d4"), 2: ("Lizard Man", "2d4"), 3: ("Harpy", "1d6")},
11: {1: ("Lizard, Gecko", "1d3"), 2: ("Neanderthal", "1d10"), 3: ("Living Statue, Crystal", "1d6")},
12: {1: ("Orc", "2d4"), 2: ("Noble", "2d6"), 3: ("Lycanthrope, Wererat", "1d8")},
13: {1: ("Shrew, Giant", "1d10"), 2: ("Pixie", "2d4"), 3: ("Medium", "1d4")},
14: {1: ("Skeleton", "3d4"), 2: ("Robber Fly", "1d6"), 3: ("Medusa", "1d3")},
15: {1: ("Snake, Cobra", "1d6"), 2: ("Rock Baboon", "2d6"), 3: ("Ochre Jelly", "1")},
16: {1: ("Spider, Crab", "1d4"), 2: ("Snake, Pit Viper", "1d8"), 3: ("Ogre", "1d6")},
17: {1: ("Sprite", "3d6"), 2: ("Spider, Black Widow", "1d3"), 3: ("Shadow", "1d8")},
18: {1: ("Stirge", "1d10"), 2: ("Troglodyte", "1d8"), 3: ("Spider, Tarantella", "1d3")},
19: {1: ("Trader", "1d8"), 2: ("Veteran", "2d4"), 3: ("Thoul", "1d6")},
20: {1: ("Wolf", "2d6"), 2: ("Zombie", "2d4"), 3: ("Wight", "1d6")},
}
# Bonus Data: Thematic OSR Wilderness Table (Monsters, Traps, Structures, Treasure, Social)
# This is vibe coded ai slop, you can adjust a real table to your liking from:
# https://github.com/Obsidian-TTRPG-Community/Old-School-Essentials-Markdown/blob/main/7.%20Monsters/4.%20Wilderness%20Encounters.md
WILDERNESS_TABLE = {
1: {1: ("Bandits", "1d6"), 2: ("Giant Rats", "2d6"), 3: ("Orc Scouts", "1d4")},
2: {1: ("Travelling Merchant", "1"), 2: ("Pit Trap (Covered)", "1"), 3: ("Giant Spider", "1d4")},
3: {1: ("Skeletons", "1d6"), 2: ("Old Standing Stones", "1"), 3: ("Bugbear", "1")},
4: {1: ("Wild Dogs", "2d4"), 2: ("Abandoned Camp", "1"), 3: ("Ghoul", "1d3")},
5: {1: ("Goblin Patrol", "2d4"), 2: ("Hidden Cache (Treasure)", "1"), 3: ("Werewolf", "1")},
6: {1: ("Giant Bats", "3d6"), 2: ("Broken Wagon (Lootable)", "1"), 3: ("Ogre", "1")},
7: {1: ("Pilgrims (Social)", "1d6"), 2: ("Swarm of Insects", "1"), 3: ("Troll", "1")},
8: {1: ("Wolves", "1d6"), 2: ("Ruined Tower", "1"), 3: ("Giant Scorpion", "1d2")},
9: {1: ("Kobold Trappers", "2d6"), 2: ("Snare Trap", "1"), 3: ("Caravan (Social)", "1d4+2")},
10: {1: ("Stirges", "1d10"), 2: ("Ancient Statue", "1"), 3: ("Shadow", "1d6")},
11: {1: ("Berserkers", "1d4"), 2: ("Fog Bank (Strange)", "1"), 3: ("Gargoyle", "1d4")},
12: {1: ("Boar, Giant", "1d4"), 2: ("Unmarked Grave", "1"), 3: ("Bandit Leader", "1")},
13: {1: ("Pixies", "2d4"), 2: ("Fairy Ring", "1"), 3: ("Spectre", "1")},
14: {1: ("Skeleton Warriors", "1d8"), 2: ("Tripwire Trap", "1"), 3: ("Wraith", "1")},
15: {1: ("Trader with Guard", "1d4"), 2: ("Cave Entrance", "1"), 3: ("Vampire", "1")},
16: {1: ("Gnolls", "1d6"), 2: ("Dead Horse (Loot)", "1"), 3: ("Chimera", "1")},
17: {1: ("Spider, Giant Crab", "1d3"), 2: ("Falling Rock Trap", "1"), 3: ("Dragon, Red (Young)", "1")},
18: {1: ("Neanderthals", "1d8"), 2: ("Stone Circle", "1"), 3: ("Giant", "1")},
19: {1: ("Noble on Horseback", "1"), 2: ("Shrine (Desecrated)", "1"), 3: ("Lich", "1")},
20: {1: ("Wandering Hermit (Social)", "1"), 2: ("Mirage/Vision", "1"), 3: ("Balrog (Demon)", "1")},
}
# Monster Encounter Table
@agent.tool_plain
async def monster_table(encounter_type: str = "dungeon", encounter_level: int = 1) -> str:
"""
Returns a basic encounter description that requires further tool use with roll_dice to complete.
Args:
encounter_type (str): The type of encounter table to roll on. Options: 'dungeon', 'wilderness'.
encounter_level (int): The level of the encounter table (1-3).
"""
# Normalize inputs
encounter_type = encounter_type.lower()
# Clamp level between 1 and 3 as per the provided tables
encounter_level = max(1, min(3, encounter_level))
table = None
if encounter_type == "dungeon":
table = DUNGEON_TABLE
elif encounter_type == "wilderness":
table = WILDERNESS_TABLE
else:
return f"Error: Unknown encounter_type '{encounter_type}'. Please use 'dungeon' or 'wilderness'."
# Roll d20
roll = random.randint(1, 20)
# Lookup entry
if roll in table and encounter_level in table[roll]:
name, quantity_dice = table[roll][encounter_level]
return f"Rolled {roll} on {encounter_type.capitalize()} Table (Lvl {encounter_level}): {name}. Quantity: {quantity_dice}."
else:
return f"Error: No entry found for roll {roll}, level {encounter_level} in {encounter_type} table."
# How Close Tool
@agent.tool_plain
async def distance_table() -> str:
"""
Rolls 1d6 to determine the starting distance of an encounter.
Outcomes:
1 = Close
2-4 = Near
5-6 = Far
"""
roll = random.randint(1, 6)
if roll == 1:
distance = "Close"
elif 2 <= roll <= 4:
distance = "Near"
else: # 5 or 6
distance = "Far"
return f"Rolled {roll} on 1d6: Starting Distance is {distance}."
# Activity Tool
@agent.tool_plain
async def activity_table() -> str:
"""
Rolls 2d6 to determine the activity of the encountered creatures.
Outcomes:
2-4: Hunting
5-6: Eating
7-8: Building/nesting
9-10: Socializing/playing
11: Guarding
12: Sleeping
"""
roll = random.randint(1, 6) + random.randint(1, 6)
if roll <= 4:
activity = "Hunting"
elif roll <= 6:
activity = "Eating"
elif roll <= 8:
activity = "Building/nesting"
elif roll <= 10:
activity = "Socializing/playing"
elif roll == 11:
activity = "Guarding"
else: # 12
activity = "Sleeping"
return f"Rolled {roll} on 2d6: Activity is {activity}."
# Reaction Tool
@agent.tool_plain
async def reaction_table(cha_modifier: int = 0) -> str:
"""
Rolls 2d6 + CHA modifier to determine the reaction of encountered NPCs or monsters.
Args:
cha_modifier (int): The charisma modifier to add to the roll. Defaults to 0.
Outcomes:
2: Attacks immedeately!
2-5: Hostile
6-8: Uncertain and Suspicious
9: Neutral
10-11: Curious
12+: Friendly
"""
base_roll = random.randint(1, 6) + random.randint(1, 6)
total = base_roll + cha_modifier
if total <= 2:
reaction = "Attacks immediately!"
if total <= 5:
reaction = "Hostile"
elif total <= 8:
reaction = "Uncertain and Suspicious"
elif total <= 9:
reaction = "Neutral"
elif total <= 11:
reaction = "Curious"
else: # 12 or higher
reaction = "Friendly"
return f"Rolled {base_roll} (2d6) + {cha_modifier} = {total}: Reaction is {reaction}."
# Language Tool
@agent.tool_plain
async def language_table() -> str:
"""
Rolls 1d6 to determine if any intellegent creature in the encounter can also speak common.
Outcomes:
1: At least one creature can speak common if intellegent.
2+: No encounter creature can speak common in addition to their nativce language.
"""
base_roll = random.randint(1, 6)
total = base_roll
if total == 1:
reaction = "At least one intelligent creature (if any) can speak common in addition to its native tongue."
else:
reaction = "No intelligent creatures (if any) can speak common in addition to their native tongues."
return f"Rolled {total} on 1d6: {reaction}"
# Treasure Tool
# From Shadowdark 1d100 Treasure Loot Table
# https://www.thearcanelibrary.com/blogs/shadowdark-blog/faq-on-the-shadowdark-rpg-third-party-license
# [Shadowdark RPG Third-Party License](https://www.dropbox.com/scl/fo/aau31be176rup3poztyqv/AIaM-I0hTSQPwNBmsCYphF4/License?dl=0&preview=Shadowdark+RPG+Third-Party+License+V1.1.pdf&rlkey=vvpdr5xffrq9vihundx9g9bqg&subfolder_nav_tracking=1)
TREASURE_TABLE = [
(1, 1, "Bent tin fork (1 cp)"),
(2, 3, "Muddy torch (2 cp)"),
(4, 5, "Bag of smooth pebbles (2 cp)"),
(6, 7, "10 cp in a greasy pouch"),
(8, 9, "Rusty lantern with shattered glass (1 gp)"),
(10, 11, "Silver tooth (1 gp)"),
(12, 13, "Dull dagger (1 gp)"),
(14, 15, "Two empty glass vials (6 gp)"),
(16, 17, "60 sp in a rotten boot"),
(18, 19, "Cracked, handheld mirror (8 gp)"),
(20, 21, "Chipped greataxe (9 gp)"),
(22, 23, "10 gp in a moldy, wood box"),
(24, 25, "Chip of an emerald (10 gp)"),
(26, 27, "Longbow and bundle of 40 arrows (10 gp)"),
(28, 29, "Dusty, leather armor dyed black (10 gp)"),
(30, 31, "Scuffed, heavy shield (10 gp)"),
(32, 33, "Simple, well-made bastard sword (10 gp)"),
(34, 35, "12 gp in the pocket of a ripped cloak"),
(36, 37, "Wavy-bladed greatsword (12 gp)"),
(38, 39, "Pair of elf-forged shortswords (14 gp)"),
(40, 41, "Golden bowl (15 gp)"),
(42, 43, "Obsidian statuette of Shune the Vile (15 gp)"),
(44, 45, "Undersized pearl (20 gp)"),
(46, 47, "Jade-and-gold scarab pin (20 gp)"),
(48, 49, "Bag of 10 silver spikes (2 gp each)"),
(50, 53, "Mithral locket with a painting of a halfling (20 gp)"),
(54, 55, "Two finely forged dwarven shields (20 gp)"),
(56, 57, "Pair of silvered daggers (10 gp each)"),
(58, 59, "Copper-and-gold mead tankard (20 gp)"),
(60, 61, "Bundle of five red dragon scales (5 gp each)"),
(62, 63, "Light, warm cloak woven of spidersilk (25 gp)"),
(64, 65, "Fine set of ivory game pieces (25 gp)"),
(66, 67, "Half-finished suit of chainmail (30 gp)"),
(68, 69, "Matched trio of warhammers (10 gp each)"),
(70, 71, "Fragment of a sapphire (30 gp)"),
(72, 73, "Set of silk slippers and a robe (35 gp)"),
(74, 75, "Silver-and-gold circlet (40 gp)"),
(76, 77, "Radiant, polished pearl (40 gp)"),
(78, 79, "Mithral shield etched with soaring dragons (40 gp)"),
(80, 81, "Gold monkey idol with a ruby gripped in its teeth (60 gp)"),
(82, 83, "Fine suit of chainmail (60 gp)"),
(84, 85, "Cracked emerald (60 gp)"),
(86, 87, "Two lustrous pearls (40 gp each)"),
(88, 89, "1st-tier spell scroll (80 gp)"),
(90, 91, "Potion of Invisibility (80 gp)"),
(92, 93, "Magic wand, 2nd-tier spell (100 gp)"),
(94, 95, "Egg of The Cockatrice (100 gp)"),
(96, 97, "+1 armor (benefit, curse) (150 gp)"),
(98, 99, "Bag of Holding (virtue, flaw) (150 gp)"),
(100, 100, "+1 magic weapon (benefit) (200 gp)"),
]
@agent.tool_plain
async def roll_treasure() -> str:
"""
Rolls 1d100 to determine a random treasure item from the OSR Treasure Table.
"""
roll = random.randint(1, 100)
# Find the corresponding entry
result = "Unknown Treasure"
for low, high, description in TREASURE_TABLE:
if low <= roll <= high:
result = description
break
# Formatting the roll display (e.g., 05 instead of 5 for single digits to match d100 style)
roll_str = f"{roll:02d}"
result = f"Rolled {roll_str} on 1d100: Potential Treasure is {result}."
print(result)
return result
@agent.tool_plain
async def random_encounter(encounter_type: str = "dungeon", encounter_level: int = 1, cha_modifier: int = 0) -> str:
result = await monster_table(encounter_type, encounter_level)
result += "\n"
result += await distance_table()
result += "\n"
result += await activity_table()
result += "\n"
result += await reaction_table(cha_modifier)
result += "\n"
result += await language_table()
result += "\n"
print(result)
return result
# ---------------------------------------------------------------------------
# Main entry‑point
# ---------------------------------------------------------------------------
async def main(deps: Optional[Any] = None) -> None:
"""
Run the agent with a static prompt and print the result.
Parameters
----------
deps
Optional dependency object that can be passed to the agent.
In this demo we keep it `None` because the agent does not need
any external resources.
"""
result = await agent.run(
# "The level 1 party stand in the wilderness in the middle of hot summer full moon night. Use the random_encounter tool. The party's face character has CHA modifier of +0. Check for treasure by rolling 1d6 with success on 3-6 use the treasure_table tool.",
"The level 2 party stand in a large dungeon room with closed doors to the north and east. Use the random_encounter tool. The party's face character has CHA modifier of +2. Check for treasure by rolling 1d6 with success on 3-6 use the treasure_table tool. Use the date tool to add a seasonal theme!",
deps=deps,
)
print("\n")
console = Console()
markdown = Markdown(result.output)
console.print(markdown)
if __name__ == "__main__":
asyncio.run(main())
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment