AI 기반 TTRPG 어드벤처 생성기를 만들었습니다 (일반적인 환각은 지루하기 때문에)
Source: Dev.to
나는 R.L. 스테인의 흥미진진한 이야기를 읽으며 자랐고, 스토리 중심의 비디오 게임에 수많은 시간을 보냈다. 성인이 된 뒤에는 테이블탑 RPG(TTRPG) 분야에 빠지게 되었는데, 이곳은 내 뇌의 파충류 부분을 간질이는 일종의 je ne sais quoi를 제공한다.
최근 들어 새로운 모험 아이디어를 떠올리는 내 능력이 점점 사라지고 있음을 느꼈다. 깜빡이는 커서를 바라보며 고생해 본 사람이라면 알겠지만, 멋진 콘셉트—예를 들어 “떠다니는 도시에서의 사이버펑크 강도 사건”—를 가지고도 구체화하려 하면 벽에 부딪히게 된다.
표준 대형 언어 모델(LLM)은 일반적인 트로프를 만들어내는 데는 뛰어나지만, “무서운 숲”을 물어보면 언제나 같은 “뒤틀린 나무와 속삭이는 바람”을 내놓는다. 영혼이 없고, 더 중요한 것은 계획성이 부족하다는 점이다.
나는 단순히 무언가를 만들어내는 것이 아니라 실제 세계의 전설, 위키, 포럼, 그리고 다른 창작자들의 작업을 조사해 기반이 탄탄하고 창의적인 모험을 생성해 주는 도구가 필요했다. 그 결과가 Adventure Weaver다. Exa를 활용해 만든 이 애플리케이션은 TTRPG 게임 마스터(그리고 일반 작가)들이 작가의 블록을 극복하고, 전체 인터넷을 절차적으로 생성된 영감 라이브러리로 전환하도록 돕는다.
연구‑후‑생성 워크플로우
- 사용자 프롬프트 – 분위기를 설명한다(예: “죽어가는 신의 등뼈 위에 세워진 도시”).
- 에이전트 – Exa를 통해 파견된 AI 에이전트는 단순히 키워드를 매칭하는 것이 아니라 개념을 이해한다.
- 스트리밍 업데이트 – 에이전트의 동작(“위키 크롤링 중…”, “블로그 읽는 중…”)이 실시간으로 사용자에게 스트리밍되어 지루한 로딩 스피너를 없앤다.
- 영감 그래프 – D3.js 시각화가 영감의 네트워크를 보여 주어, 무시무시한 악당 아이디어가 정확히 어디서 왔는지 확인할 수 있다.
기술 스택
| 구성 요소 | 이유 |
|---|---|
| Next.js | 프로덕션 수준의 React 프레임워크 |
| Exa | AI‑주도 연구를 위한 신경망 검색 엔진 |
| Tailwind CSS | 레이아웃에 시간을 들이지 않고 빠르게 UI 스타일링 |
| D3.js | 인터랙티브 “탐험 보드” 시각화 |
엄격한 JSON 스키마 정의
AI가 텍스트 벽이 아닌 사용 가능한 데이터를 반환하도록 하기 위해, 계약 역할을 하는 JSON 스키마를 정의한다. 스키마는 우리가 필요한 정확한 필드—제목, 요약, 플롯 훅, NPC, 위치—를 지정한다.
// 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'],
},
},
},
};
신경망 검색 vs. 키워드 검색
일반 검색 엔진은 “현실적인 드래곤 생물학”을 물었을 때 리스트형 기사만 반환할 수 있다. Exa의 신경망 모델은 당신이 가설적인 생물학을 찾고 있다는 것을 이해하고, 불꽃을 내뿜는 물리학을 실제로 탐구하는 틈새 블로그 글, 학술 토론, 혹은 StackExchange 스레드를 찾아낸다.
API 라우트: 연구 시작하기
엔드포인트는 Exa와 함께 연구 작업을 생성하고, 즉시 taskId를 반환한다. 클라이언트는 이를 폴링하거나 스트리밍 진행 상황을 받아볼 수 있다.
// 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 });
}
서버‑전송 이벤트(SSE)로 진행 상황 스트리밍
30초 동안 응답을 기다리는 것은 영원처럼 느껴지므로, SSE를 사용해 에이전트의 진행 상황을 스트리밍한다. 프론트엔드는 “‘중세 성’ 검색 중” 혹은 “example.com 크롤링 중” 같은 메시지를 실시간으로 받는다.
// 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',
},
});
}
인용 매핑
이 설정의 가장 멋진 점 중 하나는 생성된 각 요소를 원본 URL에 매핑할 수 있다는 것이다. 이를 통해 NPC, 위치, 플롯 훅 등에 영감을 준 특정 블로그 글이나 포럼 스레드를 태그할 수 있다.
// src/lib/citation-processor.ts
export class CitationProcessor {
private taskIdToSection = new Map();
// ...implementation continues...
}
(클래스의 시작 부분만 보여줍니다; 전체 구현은 이벤트 처리, 중복 제거, 인용 데이터 검색을 담당합니다.)
로컬에서 시작하기
- 저장소를 클론한다.
npm install로 의존성을 설치한다..env.local파일에EXA_API_KEY를 설정한다.- 개발 서버를 실행한다:
npm run dev. http://localhost:3000을 열고 Adventure Weaver에 프롬프트를 입력하기 시작한다.
테이블탑 세션을 위한 더 풍부하고, 연구 기반의 모험을 만들어 보세요!