I Built an AI-Powered TTRPG Adventure Generator (Because Generic Hallucinations Are Boring)

Published: (December 3, 2025 at 11:00 AM EST)
5 min read
Source: Dev.to

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

  1. User Prompt – You describe the vibe (e.g., “A city built on the back of a dying god”).
  2. The Agent – An AI agent dispatched via Exa understands concepts rather than just matching keywords.
  3. Streaming Updates – The agent’s actions (“Crawling wiki…”, “Reading blog…”) are streamed to the user in real time, eliminating boring loading spinners.
  4. Inspiration Graph – A D3.js visualization shows the web of inspiration, so you can see exactly where a creepy villain idea originated.

Tech Stack

ComponentReason
Next.jsProduction‑ready React framework
ExaNeural search engine built for AI‑driven research
Tailwind CSSRapid UI styling without spending hours on layout
D3.jsInteractive “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'],
      },
    },
  },
};

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

  1. Clone the repository.
  2. Install dependencies with npm install.
  3. Set EXA_API_KEY in a .env.local file.
  4. Run the dev server: npm run dev.
  5. Open http://localhost:3000 and start feeding prompts to Adventure Weaver.

Enjoy building richer, research‑backed adventures for your tabletop sessions!

Back to Blog

Related posts

Read more »