Skip to the archived replay

Tableside · Archive

TablesideTableside

One model wrote the scene. Another had to read it back.

Tableside was a text-based RPG with an AI narrator instead of a human Dungeon Master. Three of us built it over one long weekend in May 2025, put it online, and left it running for fourteen months without touching it. Producing a single turn took two different models, at two different vendors, on two different API keys, because in 2025 a model that could write a scene could not reliably also tell you what the choices in it were.

Narrator · LLM_BASE_URL · streams prose

Extractor · EXTRACT_BASE_URL · returns JSON

handoff · separate base URL · separate API key

STATUS · ARCHIVED  ·  LIVE MAY 2025 – JULY 2026  ·  189 COMMITS IN 13 DAYS  ·  3 CONTRIBUTORS  ·  2025-05-23 → 2025-06-05

The idea

It was supposed to sit on the table

The name was literal. Tableside was meant to be an app you set down in the middle of a real table, with real people sitting around it, and it would run the game: your literal AI Dungeon Master, playing with a group that already had everything else it needed except someone willing to run the campaign.

What got built over that weekend was the layer underneath. One player, one browser, text. Before an AI could sit at a table with five friends and keep a night of Dungeons and Dragons moving, it had to be able to hold a single game together on its own, and that turned out to be where all the difficulty was. Everything below is that layer, and the reasons it was harder than it sounds.

Specimen

The game, as it played

Specimen · scripted reconstruction

Prompt v0.1.1 era · 10 nodes

Nothing on this page calls a model. The database was shut down with the rest of the app, and the narrator’s raw prose was never stored to begin with The messages table only ever kept the extractor’s JSON. So this is a reconstruction, not a recovered log: the real default story, the real default character, the real interface and the real two-phase wait, with prose written to match what the game produced. Every option below is clickable. Only one path is recorded, so all of them lead to the same next node. It reconstructs the earlier arrangement, where the narrator still invented the shape of the adventure as it went; the two-phase wait shown here outlasted that and was still running at the end.

Untitled game

Started on 05/25/2025

Reconstruction
  1. Tableside11:41 PM

    The docking collar seals with a sound like a knuckle cracking, and Jimbonius Maximum steps into a vestibule that has been cold a long time. Frost furs the handrails, the emergency locker, the dead readout above the inner hatch; outside the porthole the nebula turns in slow violet coils, the same haze that has fed his scanners nothing but static for six hours. Nothing hums. Nothing draws air. In the frost on the deck there is one boot print facing the inner hatch, its edges melted smooth and refrozen, and it is a size too small to be his.

    You can:

    1. Pry the manifest plate off the docking ring and read it
    2. Force the inner hatch and start moving
    3. Crack the emergency locker for anything worth taking
    4. Wait, and watch the print for ten full seconds

Narrator · LLM_MODEL

Extractor · EXTRACT_MODEL

streamed 777 chars

returned 4 choices · { response, choices }

Your options:

handoff shown at true length · prose compressed · every option is live

The seam

It took two models to do one job.

The first model wrote. It was sent the story, the character sheet and the last twenty messages, and asked for a scene plus three or four choices. It streamed prose back word by word. That part worked almost immediately, and it worked well.

The second model read. It took the finished prose from the first model and returned one object: the narration in one field, the choices as an array of strings. It wrote nothing. Its entire job was to look at text another model had just produced and say where the list was. The buttons in the interface were built from that array. If it came back malformed, there was no game, just a paragraph.

Model 1 · streamed · no schema

The docking collar seals with a sound like a knuckle cracking, and Jimbonius Maximum steps into a vestibule that has been cold a long time. Frost furs the handrails, the emergency locker, the dead readout above the inner hatch; outside the porthole the nebula turns in slow violet coils, the same haze that has fed his scanners nothing but static for six hours. Nothing hums. Nothing draws air. In the frost on the deck there is one boot print facing the inner hatch, its edges melted smooth and refrozen, and it is a size too small to be his.

You can:

  1. Pry the manifest plate off the docking ring and read it
  2. Force the inner hatch and start moving
  3. Crack the emergency locker for anything worth taking
  4. Wait, and watch the print for ten full seconds

chat.completions.create

Model 2 · returned the structure

{
  "response": "The docking collar seals with a sound like a knuckle cracking, and Jimbonius Maximum steps into  [...] You can:",
  "choices": [
    "Pry the manifest plate off the docking ring and read it",
    "Force the inner hatch and start moving",
    "Crack the emergency locker for anything worth taking",
    "Wait, and watch the print for ten full seconds"
  ]
}

responses.parse + zodTextFormat

Left is what the narrator produced. Right is the only part the game could actually use, and it took a second model at a second vendor to get it. The buttons were built from that array. The narrator’s raw prose was never stored, so the object on the right is the only thing the database ever kept.

How it got that way, in three commits

d75391b · 2025-05-24 13:48 PDT · Improve prompt + extract choices

// TODO: REPLACE WITH LLM THAT RETURNS TYPED RESPONSES
export function extractChoices(block: string) {
  // **1. Choice**  /  1. **Choice**  /  1. Choice
  // If nothing was bold-matched, fall back to plain list

Choices were pulled out of the prose with three stacked regular expressions. The fix was written as a comment before it existed.

749d1f5 · 2025-05-24 14:54 PDT · fixed parsing back to llm stuff

async function parseLLMReply(reply: string) {
  const llmParsedReply = z.object({
    response: z.string(),
    choices: z.array(z.string()),
  });
  // ...second model reads the first model's output
}

Sixty-six minutes later the regexes were gone and a second model was reading the first one's output. The same commit cut the narrator from gpt-4.1 down to gpt-4o-mini, because every turn now cost two billable calls. The structured-output tax was paid out of prose quality.

2d0f626 · 2025-05-25 16:01 PDT · Fix extraction (use OpenAI directly, OpenRouter for LLM gen)

const openai = new OpenAI({
  apiKey:  process.env.LLM_API_KEY,
  baseURL: process.env.LLM_BASE_URL,      // OpenRouter, for prose
});

const openaiExtractor = new OpenAI({
  apiKey:  process.env.EXTRACT_API_KEY,
  baseURL: process.env.EXTRACT_BASE_URL,  // OpenAI, for JSON
});

The architecture, stated outright in a commit subject. Two clients, two vendors, two keys, one game turn. This commit only exists on an unmerged branch. It was squash-merged, so the log on main does not contain the one commit that says the design out loud.

const LLM_MODEL     = process.env.LLM_MODEL     || "openai/gpt-4o-mini";
const EXTRACT_MODEL = process.env.EXTRACT_MODEL || "gpt-4o-mini";

Out of the box, both were the same model. One slug carries a provider namespace, the other is the bare name, so the narrator went through a router that could point at anything, including the Claude 4 model that had launched the previous Thursday, while the extractor was pinned to the one API surface where a guaranteed schema existed. The same weights, reached two ways, twice per turn.

spinnerText = "Thinking...";          // the narrator is streaming
spinnerText = "Gathering choices..."; // a second model is reading it

The seam was never hidden. Players watched it every turn: the spinner said Thinking... while the narrator streamed, then switched to Gathering choices... while the second model caught up. An earlier draft of that second label read Exploring possibilities..., dressed as atmosphere. It was changed to describe what was actually happening.

The tax was paid in prose. In the same commit that introduced the extractor, the narrator was cut from a larger model down to a small one, because every turn now cost two billable calls instead of one. Better structure, worse writing, same turn.

The prompt

Rules for a narrator

There was no way to enforce behaviour except to write it down in English and hope. The prompt is where the game design actually lives, and read closely, it is a list of failure modes being patched one at a time. The prompt itself stays in the repository, but here is what it had to spend its words on, and why.

Stay in character. Never mention being an AI.
Defending againstthe assistant underneath. Left alone the model would step out of the scene to explain itself. The first version of the prompt cast it as a dungeon master; the second recast it as a narrator and added three sentences to give the rename teeth.
Never describe or assume the player's actions.
Defending againstthe model playing your turn for you. It would narrate you drawing your weapon and charging the door, and then offer four choices about what to do next. In a multiple-choice game that is not a style problem, it is the mechanic quietly dissolving.
Number the choices. Never letters, never bullets. Always open the list the same way.
Defending againstthe extractor. These rules are not for the player, they are for the second model reading the output. A fixed opening phrase is a machine-readable delimiter written into the game's literary voice, and the numbering exists because something downstream had to find the list.
Only mention the player's stats when it matters.
Defending againstrecital. The narrator would otherwise print the full hit-point block every single turn, forever.
Keep each scene to one paragraph.
Defending againstdrift, and stated twice in two different sections of the same prompt in slightly different words. It was said twice because saying it once wasn't working. That was the state of the art available: repetition, and hope.

The last edit made the rules vaguer

What the final prompt commit gave up on

Asked for at first

Settled for

A boss battle at the end, unless a luck-based alternative ending fires at a stated probabilityA boss battle at the end.
Around half of all nodes must contain combatPlenty of combat.
One line naming the boss fight as the endingTwo spelled-out terminal states: the player dies, or the player defeats the boss.

A proportion of combat nodes and a stated probability for a rare ending both require a model to keep a tally and roll dice across a conversation it was being re-fed every turn. It couldn’t, so the arithmetic came out and a vaguer word went in. The same commit had to spell out what an ending is, because arriving at the boss was being treated as the end of the story.

const HISTORY_MAX = 20; // messages sent to LLM

history.push({ role: "system", content: gameWithPrompt.prompt });

Twenty messages is about ten exchanges, against a prompt asking for a story that resolves in eight to twelve nodes with a boss fight at the end. The rules scrolled out of the window before the ending arrived, so the entire system prompt was re-injected on every single turn, appended to the end of the history rather than the front, because recency beat position for instruction-following.

The scenario it shipped with

Stories lived in the database, one row each, and got fed to the narrator alongside the rules on every turn. There were a few of them. This is the one that shipped as the default, and the one the replay above is running: a looter docking against a derelict in a nebula thick enough to hide whatever else is aboard.

You're entering a derelict spaceship you decided to dock against because you're a looter. That's what you do. Loot. There's no indication that there's anyone on the ship, however, you're not entirely sure since you're in a hazy cloudy nebula that hinders your ship's ability to track entities. You also know you're in an area known for anomalous entities and strange phenomena.

The default story, stored identically in the prompt file and in the database seed.

Name:    Jimbonius Maximum
Health:  50 HP
Weapons: Fist (10 dmg)
Armor:   None
Skill:   Dad Jokes — interrupts the enemy's next turn

The default character: named after Jim, with no armour, one weapon and a joke for a special move. Character content lived inside a template literal, because in May 2025 the prompt was the database.

Thirteen days

One enormous weekend, then ten more days

First commit: Friday 23 May 2025, 9:46pm Pacific. By Monday night, 163 commits. Then the work kept going, in smaller bursts, until it stopped for good on 5 June: 189 commits in thirteen days, by three people. Almost nobody remembers the second half, including us, which is part of why this page exists.

Navid Kabir
Founder
Hunter Duzac
Founder
Jim Bisenius
Long-time friend and advisor
Commits per day
FRI 05-235
SAT 05-2492
SUN 05-2526
MON 05-2640
TUE 05-275
WED 05-283
THU 05-296
FRI 05-301
SAT 05-314
SUN 06-011
WED 06-041
THU 06-055

189 commits in thirteen days, 163 of them in the first four. The weekend is in purple, the ten days after it in amber. Timestamps mix committer timezones and are normalised to Pacific.

It also did not start as a game. The first commit in this repository is not Tableside at all. It is 132 files of a different product the same team was already building. Fifty-five minutes later the second commit, titled allll scaffolded, deleted almost all of it: 92 files changed, 200 insertions, 3,337 deletions. A commit named “allll scaffolded” that is 94% deletions is an honest picture of how the weekend started. The RPG was carved out of a working commercial codebase, which is why an AI dungeon crawler shipped with auth, database migrations and websockets already in the box on day one. In 2025 the reusable part of an AI product was everything except the AI.

Fossils still in the tree

  • scripts/square/*.tsCard-reader pairing scripts for a payment terminal. Dependencies uninstalled, imports left dangling, still shipped.
  • scripts/echo-menu.tsCopies a restaurant menu to your clipboard. Imports a file deleted on day one.
  • src/components/icon/options/cart.tsxA shopping-cart icon that exports a component named Speaker.
  • api/utils/camelize.test.tsThe first test in the repository. Five tests, all about string casing, fixtures still describing order history.

The log, unedited

  • 05-23 22:41allll scaffolded
  • 05-23 23:35mooooooooore
  • 05-23 23:43:35final b4 sleepies
  • 05-23 23:43:41fdsafdsa
  • 05-24 01:44main bit works!
  • 05-24 02:23remove bullmq lets websocket those connections and stream llm responses, it'd be way fooking better
  • 05-24 13:11fix biggo bug
  • 05-24 14:58checkpoint b4 ai f***ery
  • 05-24 15:03ALMOST replies not working
  • 05-24 21:28expaaaaaaaand
  • 05-24 23:29redis gone poof
  • 05-25 21:46Some adjustments for prompts for Claude 4
  • 05-25 23:03Fix extremely broken stuf
  • 05-26 00:03Add retry when thing fails
  • 05-26 02:34Fix email thing and discord emojsi
  • 05-26 19:46text to speech cause why not
  • 05-27 21:24add parallaxing backgrounds for each story
  • 05-29 01:56made a thing that generates some really nice trees
  • 05-29 11:49store combatNodesPassed
  • 05-29 14:38initial implementation of db nodes, options, and npcs
  • 05-31 10:41maintaining story context
  • 06-05 12:38add characters and organized all crud operations

“final b4 sleepies” was followed six seconds later by “fdsafdsa”, and then two more hours of commits. “checkpoint b4 ai f***ery” is timestamped four minutes after the commit that invented the two-model extractor. The log knew which part was going to be hard.

Claude 4 shipped on the Thursday. By Saturday afternoon the prompt had been rewritten for it, the model id had been moved out of the source and into an environment variable, and one monolithic system message had been split into three role-tagged ones. Thirteen minutes after that rewrite, the second model was added. No model name from that work was ever committed: the actual model lived in a dashboard variable that no longer exists.

The second act

Then we took the game away from the model

The weekend proved a narrator could hold a game together, barely, with a second model following it around cleaning up. What it also proved was where the model gave out: it could not count, could not roll dice, and could not be trusted to know when the story should end. Those were the exact rules the last prompt edit of the weekend had surrendered.

The ten days after went straight at them, and the fix was to stop asking. One by one, the things the prompt had been begging for became ordinary typed code.

A decision-tree generator, in TypeScript
api/cyoa/generator.ts builds the adventure's shape in plain code before any model is called: maxBranchingFactor, minDepth, maxDepth, difficulty. No model decides the structure. There are tests.
The counting the prompt gave up on
combatNodesPassed is a number incremented in code, and minCombatFactor is a config field. The boss is placed by arithmetic on depth, not by asking nicely. The rule the prompt had downgraded to the word plenty came back as an integer.
The dice the prompt gave up on
A chance option type with a numeric probability, alongside text and skill options. The luck-based ending with a stated chance, deleted from the prompt because a model could not roll it, became a typed field.
A state machine in the database
Postgres enums for node type, option type, difficulty, ending type and skill, plus tables for nodes, options and NPCs. The game's structure stopped living in a template literal.
Characters with real stats
Strength, agility, intelligence, current and maximum health, ability power, special power, experience and a portrait, one row per user. Jimbonius Maximum finally had somewhere to live.
About thirty stories
Two migrations of new scenarios beyond the derelict ship, each with a parallax background.
Combat, on screen
A combat window with health bars, enemies and action buttons. Plus text-to-speech, because, in the words of the commit, why not.

By the fourth version of the prompt the narrator’s job had been formally reduced. Instead of inventing the shape of the adventure, it was handed a node that already existed and told to write the prose for it, with exactly as many choices as the structure already had. The model stopped deciding what happened and was left deciding only how it read.

Which is the same conclusion the two-model hack had been circling from the start, arrived at deliberately instead of by accident. In 2025 the writing was the part that worked, so in the end that is the only part it was given.

Every story got a place

  • above clouds
  • cave
  • cemetery
  • city 1
  • city 2
  • city 3
  • crystal 1
  • crystal 2
  • desert
  • dungeon 1
  • dungeon 2
  • forest
  • forest 2
  • grasslands
  • industrial
  • lava
  • medieval
  • mine
  • mountains
  • ruins
  • sea
  • sky
  • space
  • temple

24 environments, each four 320 by 64 pixel layers meant to scroll at different speeds behind the text. Hunter wired them in on the fifth day, one per story, and they do something no amount of architecture description can: they make it feel like somewhere you could actually go. Shown here composited and still.

What shipped

What it actually was

Stack
Bun · Postgres · Redis · WebSockets · Next 14
Narrator
LLM_MODEL, streamed, no schema, via LLM_BASE_URL
Extractor
EXTRACT_MODEL, with a separate base URL and a separate API key
Context
20 messages. About ten turns. It forgot everything older.
Prompts
Four versions, v0.1.0 through v0.1.3, in thirteen days. The last one narrates a structure it is handed.
Stories
About thirty, one row each, re-sent to the narrator every turn.
Accounts
INSERT INTO users DEFAULT VALUES. No email, no password, no login. Identity was a cookie.
Analytics
A Discord webhook.
Errors
ws.send("⚠️ LLM error"), a non-JSON frame the frontend discarded.
Tests
Five for string casing, written on day one. Then a suite for the tree generator, written on day seven.
Built
2025-05-23 to 2025-06-05. 189 commits, 163 of them in the first four days.
Live until
July 2026, untouched for fourteen months.

The “Already have an account? Login” button routed to /logout. The observability stack was a handful of Discord messages, and the last commit of the opening weekend, at 2:34 in the morning, was mostly fixing broken emoji shortcodes in them. Its subject line misspells “emojis”.

What changed

What got easier, and what didn't

Getting prose and structured choices out of one call
One request, with a schema attached. The model returns both.
Keeping the rules in context for a ten-turn game
The window makes the problem disappear. The system prompt stays put instead of being re-pasted every turn.
Holding hit points, inventory and node count across turns
Still your job, not the model's. That is exactly what happened here: the counting moved out of English and into TypeScript, and the prompt was left to write.
Swapping models when a new one launches
Also still easy. This is the one thing the sprint got right, under launch-day pressure, by deleting a hardcoded string.

Structured output stopped being a second model’s job. Asking for a schema and getting conforming data back arrives in the same call that writes the prose, so the entire reason this had two vendors, two API keys and two spinner states has been absorbed into the ordinary way you call a model. The regexes, the extractor, the delimiter phrase smuggled into the narration and the retry button are one request today. That solves the plumbing. Whether it adds up to a game worth sitting down to is a separate question, and not one the plumbing answers.

Basically, the question was, “If we had an AI run a DnD Campaign, could it?” Turns out, the answer was complicated in those days. I’m not sure if you can still do it and have it be an interesting experience based on everything we know about LLMs today.
Navid Kabir, Founder, N-GON Interactive

Colophon

Repository
189 commits on main, plus branches never merged
Range
2025-05-23 21:46:50 -0700 → 2025-06-05 12:41:17 -0400
Archive tags
v0.1.1-archive, the end of the opening weekend · v0.1.3-archive, the app as it last ran
Prompts
Four versions, v0.1.0 through v0.1.3. Their text is not published here. The version that ran in production was v0.1.3, which the repository cannot show, because it lived in a dashboard variable that is gone.
Replay
Reconstructed against v0.1.1, when the narrator was still deciding the shape of the story rather than being handed one.
This page
A static export. No server, no database, no API, no model call.