Top
Design patterns · Technique note

Keeping a refused quest refused

Tabled logo, Short Hill Labs, LLC

Created by Short Hill Labs, LLC · Developer of Tabled · 30 October 2026

First entry badgeFounding contributor badge

Short Hill Labs, LLC wrote these notes with AI help, per the team. Tabled is a platform Arcanum has reviewed. Contributing to the library does not affect its review or score.

TypeTechnique note
Version1.0
Last updated
LicenceCC BY 4.0
Rights attestedYes, by the author
StatusMaintained, in production

When a player turns down a quest, an AI storyteller tends to keep the quest alive anyway. News of it arrives every turn, and its symbols turn up in every new storyline. This is how Tabled makes a refusal stick: the model gives the verdict, and the server holds it from then on.

The problem

A player in a long campaign says "not our fight" and leaves. A good human game master lets the world move on. Our storyteller model did not. It kept reporting on the refused quest in parallel, sometimes every turn, as if the player had only paused.

Two things made it worse.

  1. Our own tools pulled the other way. We had a helper that reminds the model about the main quest if it goes quiet for a few rounds. Nothing told that helper the quest had been refused, so it kept asking for the refused quest to come back within reach.
  2. "Stop mentioning it" is not one thing. A quest has a title, but it also has its signs: a seal, a bell, a name, a letter. The model can stop saying the title and still send the seal after the hero in every new storyline.

What failed first

We tried these in roughly this order. Each one taught us something.

  1. A rule in the system prompt. "The world does not wait, and a declined quest resolves without the party." The model agreed with it and mostly ignored it in long play. Prose rules in a system prompt do not reliably bind the model once the context is full of story.
  2. Having the server guess the refusal. We could not tell "we refuse" from "not yet, we need supplies first" with keyword rules. Only the model reads the fiction well enough to make that call. So the model gives the verdict, and the server enforces it.
  3. Retiring the quest by its title. This worked for the title, which never came back. But in our own test campaigns the refused quest's symbol came back in every new storyline (a cargo mark, a letter, a stranger's ring). A title matcher could not see it, because the symbol never shared a word with the title. It lived in the open mysteries list, which we were also telling the model to deepen.
  4. Forgetting the refusal at chapter breaks. When a new episode opened, the opener wrote a fresh quest list and the refused quest fell off it. Every guard went quiet at once, and the refused story came back as the new episode's hook.
  5. A refusal phrase list written by us. Real refusals sound like "why should I bleed for strangers" or "what do I care about some chapel". None of those matched our first list, so the model was never asked for a verdict. We now grow the list from real lines, and a false match is cheap (it only asks a question).

What works

1. One shape for the verdict, written by the model

The quest log is part of the model's structured output. A refused quest is a quest marked done whose note starts with an exact prefix and names the thread's marks, the recurring signs that would bring it back.

{
  "title": "The Bell at Coldharrow Chapel",
  "status": "done",
  "note": "Left behind [marks: passing-bell / death knell, thorned wheel and key]: the village rang its own bell and buried its own dead."
}

The model is the only reader that knows which signs carry a thread, so it names them. It also gives each mark another way the story might say it, after a slash, so "death knell" counts as the passing-bell. Your code only has to parse a prefix and a bracket.

2. Ask for the verdict when it matters

When a player's action sounds like a refusal, add a short line to that turn's instructions: "If the party has refused or walked away from this quest, mark it done this turn in this shape. If they are only delaying, leave it active." Asking in the moment works far better than a standing rule. A false alarm costs one sentence.

3. The server holds it from then on

Once a quest carries the marker, your code owns it.

  • It is never picked as the main quest again.
  • If the model marks it active again, that change is refused, unless the player's own action that turn names it. The player can always go back. The model cannot drag them back.
  • Any open mystery that carries the title or a mark is dropped, and new ones are refused.
  • Each turn's instructions carry one line naming the threads the party left, asking the model not to report on them or bring back their marks.
  • No reward is paid for a quest the party left.
  • At an episode or chapter break, refused quests are carried into the new quest list, and the opener is told a refused thread is not its hook.
  • The running campaign summary is reread every turn, so it is the likeliest way back in. When the model rewrites it, the server drops every sentence that carries a refused thread and adds one fixed line of its own: the party left this quest behind.
  • A player who only asks what became of a refused quest is told how the world closed it. Only setting out to take it up again reopens it.

A minimal version of the reader, in TypeScript:

const LEFT = /^\s*left behind\b/i;
const MARKS = /\[\s*marks?\s*:\s*([^\]]*)\]/i;

type Quest = { title: string; status: string; note?: string };

export const isLeft = (q: Quest) => q.status === "done" && LEFT.test(q.note ?? "");

export const marksOf = (q: Quest) =>
  (q.note?.match(MARKS)?.[1] ?? "").split(/[,;]/).map((m) => m.trim()).filter(Boolean).slice(0, 3);

// A mark counts in prose when its two most particular words both appear as
// whole words, so "a seal stamped with barley" matches "key-and-barley seal"
// and "bells" counts for "bell", but "belly" and "bellows" do not.
const STOP = new Set(["the", "and", "with", "from", "for", "that", "this", "old", "mark", "sign"]);

export const markKeys = (mark: string) =>
  [...new Set(mark.toLowerCase().split(/[^a-z]+/).filter((w) => w.length >= 3 && !STOP.has(w)))]
    .sort((a, b) => b.length - a.length)
    .slice(0, 2);

// A mark's other wordings follow a slash: "passing-bell / death knell".
export const markTouched = (mark: string, prose: string) => {
  const text = prose.toLowerCase();
  return mark.split("/").some((form) => {
    const keys = markKeys(form);
    return keys.length > 0 && keys.every((k) => new RegExp("\\b" + k + "(?:s|es|ed|ing)?\\b").test(text));
  });
};

4. Give the player a button too

Reading the fiction will sometimes miss. So players also have a direct control, "turn from this quest". When they use it, nothing is left to interpretation. The server closes the quest that turn whether or not the model did, and only asks the model how the world closed it and which marks it leaves.

5. Count it

Every rule above has a counter: verdicts given, verdicts that named marks, turns a refused thread was active, turns the prose named it or its marks anyway, and turns the story's closing question asked the party about it. A sign in the narration can be fair aftermath; a question about it is a hook. These numbers are how we found failures 3, 4 and 5. Without them every test looked like a success, because the title never came back.

What changed

On a test campaign run after the mark-based version shipped, the model named the marks itself in the verdict, and across the next ten turns the refused thread's marks appeared zero times. The earlier version brought them back in every storyline. The next episode opened on where the player actually went. How often the story raises a left thread anyway is published on how the dice stay fair.

What we have not solved

  • If the campaign's opening premise is the refused thread, the opener is told it is where the hero began, not where the story must return. That is still a prompt rule, not a server rule.

The general lesson

Let the model judge the fiction, but give its judgment a shape your code can read, and let your code enforce it. A rule the model has to remember will be forgotten in a long game. A fact the server holds will not. The wider pattern is in the storyteller never holds the dice.

Source (original, may be newer): tabled.gg/notes/refused-quests

By Short Hill Labs, LLC, makers of Tabled. Published under CC BY 4.0: share and adapt it freely, crediting "Short Hill Labs, LLC". The other entry: The storyteller never holds the dice. Tabled is made by Short Hill Labs LLC, a Virginia limited liability company. Game rules derive from the System Reference Documents 5.1 and 5.2.1, licensed under CC-BY-4.0; Tabled is unofficial and not affiliated with or endorsed by Wizards of the Coast. Arcanum has not tested or audited these claims. Being in the library does not affect any review or score. Licence information on this page is a reference, not legal advice.

← Back to the Community Library