VAMPMAKER — Turning a demo into a billable product
A SaaS that prepares the role-playing session before the session starts · SaaS · Niche B2C · TRPG
- Client
- Side project — RBT Studio
- Role
- Full Stack / AI Engineer — product, architecture and development
- Timeline
- May 2026 – ongoing (Release 1.0)
- Stack
- TypeScript, NestJS 11, Next.js 16, React 19, Prisma, PostgreSQL · Neon, Zustand, Tailwind 4, OpenRouter, Pollinations.ai
Running a chronicle is closer to producing a series than to playing a game
The Storyteller arrives on Friday with a five-session arc to sustain, a city with its political hierarchy, a dozen NPCs that must sound different from each other and character sheets that match the edition being played. Almost all that work happens before anyone rolls a die. VampMaker was born to absorb that preparation, and the first version did it well. The problem was what happened next.
The problem: Nothing generated survived the browser
A code audit in August 2026 put the diagnosis in one sentence. The store wrote only to localStorage, and the full CRUD of the NestJS backend — twenty-odd endpoints, with their PostgreSQL schema, relations and cascade deletes — was never invoked: saveCampaign() and loadCampaigns() were written, tested against the API and had not a single call from the interface. The result was a product that presented itself as a SaaS — with an account, tiers, a “Free Plan” in the sidebar — and lost the user’s campaign when they switched devices. The real brief wasn’t adding AI: it was turning a working demo into a billable product.
How it was approached
- The spec before the code. With a finding of that calibre the temptation is to open the editor and start wiring things up. I wrote the specification first: four SDD documents — SPEC, ARCHITECTURE, SCAFFOLD, AGENTS — that fix goals, non-goals and verifiable acceptance criteria before touching a line. Defining the non-goals turned out to be as useful as the goals: payment gateway, multi-user collaboration, native app and PDF export were left out of Release 1.0 in writing, and that closed the door on three months of scope drift.
- Eight agents with their dependency graph. On top of that spec I set up a harness of eight agents. The project is developed by one person, so the split isn’t about human parallelism: it is about every work session — my own or with an LLM — having a closed scope and a checkable definition of done.
- Six decisions documented as ADRs. The server becomes the source of truth and localStorage drops from store to read-through cache. String identifiers (cuid) end to end, because the frontend assumed number and as soon as Prisma emits cuids the user sees “Campaign not found” over what is really a typing problem — this ADR blocks all the others. Whatever doesn’t fit the schema goes into Json columns, not new tables. Synchronous write-through persistence per operation: one extra request is irrelevant next to the 10–30 seconds of a generation, and in exchange a failure is attributable to a specific action. A strict API contract with a single error envelope. And quota as a Guard that reserves beforehand and an Interceptor that confirms afterwards, so a failed generation doesn’t consume balance.
- The AI layer, treated as engineering rather than conversation. Connecting OpenRouter is twenty lines. The hard part was getting the model to always return something renderable, in the right language and without contradicting the canon of the chosen edition. Language: some of the English leaking in didn’t come from the model but from a template in the code itself, and the explicit rule now separates keys (untouched, consumed by the frontend) from values (in Spanish, with the game terminology translated). Schema: “return ONLY valid JSON” is not a specification, so the prompt now carries the explicit schema with its branching between editions. Layered canon: a universal core, a per-edition layer and the curses only of the clans that appear in the request — before, all fifteen were injected and “the Masquerade is absolute law” was asserted even in a Dark Ages campaign, where it isn’t declared until 1666.
8-agent harness: CostProbe → ContractMigrator → PersistenceBuilder → ResilienceBuilder → ExperienceBuilder → QuotaBuilder → DocWriter
Solution: A monorepo with three workspaces and a contract that breaks the build, not production
backend, frontend and shared, where the shared package holds the domain types both ends consume. A NestJS 11 backend with per-domain modules, Prisma on PostgreSQL in Neon, JWT in an httpOnly cookie and a global exception filter. A Next.js 16 frontend with React 19, App Router, Zustand and a tabbed campaign view: arc, sessions, NPCs, sheets and maps. A generation engine with six endpoints on deepseek-chat via OpenRouter for text and Pollinations.ai for images — the image prompt is deliberately kept in English, because diffusion models perform worse in Spanish: what gets translated is what the user sees, not what the generator consumes. Four supported editions (V5, V20, Dark Ages and Sabbat), each with its lore, factions and city offices.
Results
There are no usage metrics to show: the product is in its Release 1.0 phase and server-authoritative persistence is in progress. What there is, is accumulated judgement, which is what really travels from one project to the next. Release 1.0 closes server-authoritative persistence, non-silent errors, a single navigation path, Markdown export to bring the material to the table and a per-account quota limit.
- 6 ADRs — structural decisions, each with its discarded alternative
- ~190 — tokens saved per generation by no longer repeating the language rule six times
- 4 — supported editions, each with its lore and offices
"Before touching the prompt, find out who writes the text. I spent a while convinced the English came from the model. Part of it came from a template in the code itself."
What was learned
- A prompt without a schema is an API without a contract. “Return valid JSON” is not a specification. If the interface expects fifteen specific keys, those fifteen keys go in the prompt — and the ones the code consumes are not translated even if the rest of the content is.
- Context that contradicts the domain costs more than missing context. Injecting Camarilla rules into a medieval campaign isn’t just wasting tokens: it is actively asking the model to get it wrong. Splitting the canon by edition improved the output and made it cheaper.
- A hook with useState is not shared state. Every component that called useAuth had its own user and its own request. Since the sidebar lives in the root layout and doesn’t remount on navigation, login really worked — cookie issued, valid session — but the app kept rendering as if it hadn’t. A state architecture error disguised as an authentication bug.
- Writing the spec first turned “fix the app” into eight verifiable units. It’s the difference between a refactor with no known end and eight blocks with a definition of done.
Repository and demo yet to be published.