I Built an AI-Powered TTRPG Adventure Generator (Because Generic Hallucinations Are Boring)
Source: Dev.to
I grew up reading the gripping narratives of R.L. Stine and spending countless hours with story‑driven video games. As an adult I’ve fallen into the tabletop RPG (TTRPG) space because it hits a certain je ne sais quoi that tickles my lizard brain.
Lately I’ve noticed my own ability to conjure fresh adventure ideas has faded. Anyone who has stared at a blinking cursor knows the struggle: you have a cool concept—“a cyberpunk heist in a floating city”—but when you try to flesh it out you hit a wall.
Standard large language models (LLMs) are great at hallucinating generic tropes. Ask for a “scary forest” and you get the same old “twisted trees and whispering winds.” It lacks soul and, more importantly, it lacks planning.
I wanted a tool that didn’t just make things up but actually researched real‑world lore, wikis, forums, and other creators’ work to generate grounded, creative adventures. The result is Adventure Weaver, an application built with Exa that helps TTRPG Game Masters (and writers in general) overcome writer’s block by turning the entire internet into a procedurally generated library of inspiration.
Research‑then‑Generate Workflow
- User Prompt – You describe the vibe (e.g., “A city built on the back of a dying god”).
- The Agent – An AI agent dispatched via Exa understands concepts rather than just matching keywords.
- Streaming Updates – The agent’s actions (“Crawling wiki…”, “Reading blog…”) are streamed to the user in real time, eliminating boring loading spinners.
- Inspiration Graph – A D3.js visualization shows the web of inspiration, so you can see exactly where a creepy villain idea originated.
Tech Stack
| Component | Reason |
|---|---|
| Next.js | Production‑ready React framework |
| Exa | Neural search engine built for AI‑driven research |
| Tailwind CSS | Rapid UI styling without spending hours on layout |
| D3.js | Interactive “exploration board” visualizations |
Defining a Strict JSON Schema
To ensure the AI returns usable data (instead of a wall of text) we define a JSON schema that acts as a contract. The schema specifies the exact fields we need: title, summary, plot hooks, NPCs, and locations.
// src/app/api/generate/route.ts
const adventureSchema = {
type: 'object',
required: ['adventure_title', 'summary', 'plot_hooks', 'npcs', 'locations'],
properties: {
adventure_title: { type: 'string' },
summary: { type: 'string' },
plot_hooks: { type: 'array', items: { type: 'string' } },
npcs: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
required: ['name', 'description'],
},
},
locations: {
type: 'array',
items: {
type: 'object',
properties: {
name: { type: 'string' },
description: { type: 'string' },
},
required: ['name', 'description'],
},
},
},
};
Neural Search vs. Keyword Search
A normal search engine might return a listicle when you query “realistic dragon biology.” Exa’s neural model understands that you’re looking for speculative biology and can surface niche blog posts, academic discussions, or StackExchange threads that actually explore the physics of fire‑breathing.
API Route: Kick Off the Research
The endpoint creates a research task with Exa, returns a taskId immediately, and lets the client poll or stream progress.
// src/app/api/generate/route.ts
import Exa from 'exa-js';
import { NextRequest, NextResponse } from 'next/server';
const exa = new Exa(process.env.EXA_API_KEY);
export async function POST(req: NextRequest) {
const { prompt } = await req.json();
const instructions = `You are a creative assistant for a TTRPG Game Master.
Use the user's prompt to find ideas from blogs, forums, and wikis to generate a compelling adventure.
Please generate a title, a summary, a few plot hooks, some interesting NPCs, and some key locations for the adventure.
Each component should be placed in its respective schema field.
For context, here is the user's prompt: ${prompt}`;
// Create the research task without awaiting its completion.
const researchTask = await exa.research.create({
instructions,
outputSchema: adventureSchema,
});
// Return the task ID right away.
return NextResponse.json({ taskId: researchTask.researchId });
}
Streaming Progress with Server‑Sent Events (SSE)
Waiting 30 seconds for a response feels like an eternity, so we stream the agent’s progress using SSE. The frontend receives messages such as “Searching: ‘medieval castles’” or “Crawling: example.com” in real time.
// src/app/api/adventure/[taskId]/route.ts
import Exa from 'exa-js';
import { NextRequest, NextResponse } from 'next/server';
import { CitationProcessor } from '@/lib/citation-processor';
const exa = new Exa(process.env.EXA_API_KEY);
export async function GET(_req: NextRequest, context: any) {
const taskId = (await context.params).taskId;
const stream = new ReadableStream({
async start(controller) {
const encoder = new TextEncoder();
const citationProcessor = new CitationProcessor();
const taskStream = await exa.research.get(taskId, { stream: true });
for await (const event of taskStream) {
citationProcessor.processEvent(event);
if (
event.eventType === 'task-operation' ||
event.eventType === 'plan-operation'
) {
const op = event.data;
let message: string;
switch (op.type) {
case 'search':
message = `Searching: "${op.query}"`;
break;
case 'crawl':
message = `Crawling: ${new URL(op.result.url).hostname}`;
break;
case 'think':
message = op.content;
break;
default:
message = 'Starting an unknown journey...';
}
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'message', content: message })}\n\n`,
),
);
} else if (
event.eventType === 'research-output' &&
event.output.outputType === 'completed'
) {
const finalResult = event.output;
const deduplicatedCitations = citationProcessor.getCitations();
const resultData = {
...finalResult.parsed,
citations: deduplicatedCitations,
};
controller.enqueue(
encoder.encode(
`data: ${JSON.stringify({ type: 'result', content: resultData })}\n\n`,
),
);
break;
}
}
controller.close();
},
});
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
},
});
}
Citation Mapping
One of the coolest aspects of this setup is the ability to map each generated element back to its source URL. This lets us tag NPCs, locations, or plot hooks with the specific blog post or forum thread that inspired them.
// src/lib/citation-processor.ts
export class CitationProcessor {
private taskIdToSection = new Map();
// ...implementation continues...
}
(Only the beginning of the class is shown; the full implementation handles event processing, deduplication, and retrieval of citation data.)
Getting Started Locally
- Clone the repository.
- Install dependencies with
npm install. - Set
EXA_API_KEYin a.env.localfile. - Run the dev server:
npm run dev. - Open
http://localhost:3000and start feeding prompts to Adventure Weaver.
Enjoy building richer, research‑backed adventures for your tabletop sessions!