Isekai habit RPG · your real to-dos, run as an adventure by an AI Guide

Build a habit RPG where an AI Guide turns your chores into quests.

A build-along reference for Questling: you are summoned into the meadow-hamlet of Emberbrook, whose little guild hall flies the Wanderlight banner, and a palm-sized sprite named Lumi becomes your Guide. Your mundane tasks — email the dentist, run 3km, read 20 pages — get transformed into cozy, warm-hearted quests. You earn XP, coins and gems, level up, and the town grows as you build real habits. Two AI engines drive it: a Quest-smith that rewrites tasks into quests, and Lumi, a tool-calling agent who runs the whole board. Read it, code it, play it, tick it off.

the core loop, rendered — one mundane task, one warm quest

Side quest · dailyEasy

A Missive to the Toothwright

Emberbrook’s toothwright keeps careful ledgers, but only for those who write ahead. Send word to claim your place in her book — a small errand that keeps a larger ache away.

✎ from your task: “email the dentist to book a checkup”

★ 15 XP● 8 coins◆ 1 gem
TodoQuestRewardBoardGuideArcLetter
12milestones
Claudeprimary · provider seam
Next.js+ FastAPI + Postgres
warmcozy village · never grimdark
M00

Foundations: the model seam, config, structured outputs

Why the swappable LLM every later stage plugs into  ·  Write ~4 small files  ·  New a provider port, structured outputs, a fake for tests

Questling turns your real to-do list into a game you actually want to open. You add a mundane task; the system rewrites it as a warm little quest, hands you rewards for finishing, and lets an AI Guide — Lumi — run the board with you. Under the cozy surface it is a normal, testable application: a quest repository, a deterministic economy, and a handful of LLM calls behind one seam.

text
your to-do  ──▶  QUEST-SMITH (structured output)  ──▶  a themed Quest
                                                          │
                        LEDGER (deterministic) ◀──────────┘  pays XP / coins / gems
                                                          │
player + tasks + arcs ──▶  LUMI, the Guide  ──▶  create · split · schedule · plan · encourage
                           (a tool-calling agent over the same board)

built once per world:  WORLD BIBLE  (town · guild · Lumi's voice · tone contract)

Everything downstream depends only on an LLM port, never on a concrete vendor — the same seam the other builds on this site use, so the shapes will feel familiar.

bash
mkdir questling && cd questling
uv init backend && cd backend
mkdir -p app/{world,quests,economy,guide,arcs,serve} tests data
uv add anthropic pydantic pydantic-settings
uv add sqlmodel psycopg[binary] fastapi "uvicorn[standard]"
uv add --dev pytest

# the board lives in Postgres — run one locally (Docker):
docker run -p 5432:5432 -e POSTGRES_PASSWORD=questling -e POSTGRES_DB=questling postgres:16

app/llm.py — the provider port

A tiny interface with three shapes we reuse everywhere: complete for free-form text (Lumi’s chatter), parse for structured outputs (schema-valid quests, arcs), and stream for live replies.

python
from typing import Protocol, Type, TypeVar
from pydantic import BaseModel

T = TypeVar("T", bound=BaseModel)

class LLM(Protocol):
    def complete(self, system: str, messages: list[dict], *, max_tokens: int = 1200) -> str: ...
    def parse(self, system: str, prompt: str, schema: Type[T]) -> T: ...
    def stream(self, system: str, messages: list[dict], *, max_tokens: int = 1200): ...  # yields str
    def act(self, system: str, messages: list[dict], tools: list[dict]) -> dict: ...      # tool-use turn
python
import anthropic

class ClaudeLLM:
    def __init__(self, model: str = "claude-opus-4-8"):
        self.client = anthropic.Anthropic()      # reads ANTHROPIC_API_KEY
        self.model = model

    def complete(self, system, messages, *, max_tokens=1200) -> str:
        r = self.client.messages.create(
            model=self.model, max_tokens=max_tokens, system=system, messages=messages)
        return next(b.text for b in r.content if b.type == "text")

    def parse(self, system, prompt, schema):
        # structured outputs: the response is validated against the pydantic schema
        r = self.client.messages.parse(
            model=self.model, max_tokens=2000, system=system,
            messages=[{"role": "user", "content": prompt}], output_format=schema)
        return r.parsed_output

    def stream(self, system, messages, *, max_tokens=1200):
        with self.client.messages.stream(
                model=self.model, max_tokens=max_tokens, system=system, messages=messages) as s:
            yield from s.text_stream

    def act(self, system, messages, tools):       # one tool-use turn — the Guide's engine (M04)
        r = self.client.messages.create(
            model=self.model, max_tokens=1500, system=system, messages=messages, tools=tools)
        return r

class FakeLLM:                                     # tests inject this — no keys, no network
    def __init__(self, canned: dict | None = None): self.canned = canned or {}
    def complete(self, system, messages, **k): return self.canned.get("text", "...")
    def parse(self, system, prompt, schema):   return self.canned["obj"]
    def stream(self, system, messages, **k):   yield self.canned.get("text", "...")
    def act(self, system, messages, tools):    return self.canned.get("turn")
M01

The world bible: Emberbrook, the Wanderlight guild, and Lumi’s voice

Why the tone every quest is rewritten into  ·  New a world model, a persona, a tone contract  ·  Output one bible, built once, cached forever

The look-and-feel you promised — warm, cozy, an anime village that feels calm and kind — does not live in the CSS alone. It lives in a world bible that every LLM call reads: the town, the guild, the regions the player will unlock, and above all Lumi’s voice and a tone contract that keeps the whole game from drifting grimdark, snarky, or neon.

python
from pydantic import BaseModel, Field

class Guide(BaseModel):
    name: str = "Lumi"
    kind: str = "a palm-sized wanderlight sprite who works the guild's reception desk"
    voice: str = Field(description="how Lumi speaks: warm, encouraging, a little playful — 1-2 sentences")
    catchphrases: list[str] = []

class Region(BaseModel):
    name: str
    blurb: str
    unlocks_at_level: int

class WorldBible(BaseModel):
    town: str = "Emberbrook"
    guild: str = "the Wanderlight"
    premise: str = Field(description="the isekai framing: how the player arrives, what the guild is for")
    guide: Guide
    regions: list[Region]
    tone_keep: list[str]  = Field(description="the feelings every quest should evoke")
    tone_avoid: list[str] = Field(description="tones that break the world — grimdark, cruelty, sarcasm, hustle-culture")
python
BIBLE_SYS = (
    "You are the world-builder for Questling, a COZY isekai habit RPG. The mood is a warm anime "
    "village at golden hour: hopeful, gentle, a little whimsical. Never grimdark, never sarcastic, "
    "never hustle-culture ('grind', 'crush it'). Build a small starter world around Emberbrook and "
    "the Wanderlight guild, with a kind Guide named Lumi. Keep it welcoming to a nervous newcomer.")

def seed_world(llm) -> WorldBible:
    return llm.parse(BIBLE_SYS, "Seed the starter world bible for Questling.", WorldBible)
M02

The Quest-smith: turn a mundane task into a warm quest

Why the beating heart — the transform the whole product sells  ·  New structured extraction, difficulty inference  ·  Output one Quest per task

Here is the engine that earns its name. A player types “email the dentist” and gets back a quest: a title, a line of flavor grounded in the bible’s tone, a type, an inferred difficulty, and a set of tags. We use structured outputs so the model returns a schema-valid Quest we can store and score — not a wall of prose we have to parse.

python
from pydantic import BaseModel, Field
from typing import Literal

QuestType = Literal["main", "side", "daily", "bounty"]
Difficulty = Literal["trivial", "easy", "normal", "hard", "epic"]

class Quest(BaseModel):
    title: str        = Field(description="a short, evocative quest name — cozy, never grim")
    flavor: str       = Field(description="1-2 warm sentences framing the task as a small adventure")
    source_task: str  = Field(description="the player's ORIGINAL task, verbatim — never obscure it")
    type: QuestType
    difficulty: Difficulty = Field(description="inferred effort: trivial..epic")
    tags: list[str]   = Field(description="e.g. health, admin, study, home, social")
    # xp / coins / gems are NOT here — the model never mints currency. The ledger assigns them (M03).
python
QUESTSMITH_SYS = (
    "You are the Quest-smith of the Wanderlight guild. Rewrite the adventurer's real task as a small, "
    "warm quest set in Emberbrook. Rules: (1) keep the ORIGINAL task legible — the player must still "
    "know exactly what to do in real life; put it verbatim in `source_task`. (2) Match the world's "
    "tone: {tone_keep}. Never: {tone_avoid}. (3) Infer difficulty honestly from real effort, not drama. "
    "(4) One quest per task. Keep flavor to 1-2 sentences — charming, not a novel.")

def smith(llm, task: str, bible) -> Quest:
    system = QUESTSMITH_SYS.format(tone_keep=bible.tone_keep, tone_avoid=bible.tone_avoid)
    return llm.parse(system, f"Adventurer's task: {task!r}", Quest)

what the Quest-smith returns, rendered on the board

Main questNormal

The Three-Kilometre Trail

The path past the mill loops through the barley and back before the tea goes cold. Lace up, and let Emberbrook wake around you.

✎ from your task: “go for a 3km run”

★ 30 XP● 18 coins◆ 2 gems
M03

The economy: XP, coins, gems, levels — and why the model never mints them

Why rewards must feel fair or the game dies  ·  New a deterministic ledger, a level curve, streak bonuses  ·  Output a reproducible payout

A habit game lives or dies on whether rewards feel fair. If the model hands out 500 XP for watering a plant because the flavor got florid, the numbers stop meaning anything. So the split is strict: the model tags difficulty; deterministic code assigns the reward. The Quest-smith proposes; the ledger disposes.

python
from dataclasses import dataclass

# difficulty → reward. One table, tuned by hand, identical for every quest of that tier.
REWARDS = {
    "trivial": (5,  3,  0),
    "easy":    (15, 8,  1),
    "normal":  (30, 18, 2),
    "hard":    (60, 40, 4),
    "epic":    (120, 90, 8),
}  # (xp, coins, gems)

@dataclass
class Payout:
    xp: int; coins: int; gems: int; leveled_up: bool; new_level: int

def level_for_xp(total_xp: int) -> int:
    # gentle curve: level N needs 50 * N^1.6 cumulative XP. Never punishing, always climbing.
    lvl = 1
    while total_xp >= int(50 * (lvl ** 1.6)):
        lvl += 1
    return lvl
python
def award(player, quest: Quest, streak_days: int = 0) -> Payout:
    xp, coins, gems = REWARDS[quest.difficulty]
    # streak bonus: +5% XP per consecutive day, capped at +50%. A carrot, never a stick.
    xp = int(xp * (1 + min(streak_days, 10) * 0.05))
    before = level_for_xp(player.xp)
    player.xp += xp; player.coins += coins; player.gems += gems
    after = level_for_xp(player.xp)
    return Payout(xp, coins, gems, after > before, after)

the adventurer HUD, rendered — level, XP to next, and the purse

7
Level 7 · Wanderling640 / 1000 XP
● 312 coins◆ 24 gems
M04

Lumi, the Guide: a tool-calling agent that runs the board

Why the bot that operates the whole quest board conversationally  ·  New tool definitions, an agent loop, validated actions  ·  Output a Guide that acts, not just chats

This is the second engine. The player talks to Lumi in plain language — “what should I do today?”, “this feels too big”, “add a reminder to call mum” — and Lumi acts: she reads the board, creates and splits quests, reschedules, and plans arcs, all through tools. The same service functions the API uses become the agent’s hands, so there is one source of truth for what a ‘quest’ is.

python
# Tools are just JSON schemas over the real service functions. One registry, reused by API + agent.
TOOLS = [
    {"name": "list_quests",  "description": "List the player's open quests (optionally by tag or due date).",
     "input_schema": {"type": "object", "properties": {"status": {"type": "string"}}}},
    {"name": "create_quest", "description": "Smith a new quest from a plain task and add it to the board.",
     "input_schema": {"type": "object", "properties": {"task": {"type": "string"}},
                      "required": ["task"]}},
    {"name": "break_down_quest", "description": "Split one quest into 2-4 smaller sub-quests.",
     "input_schema": {"type": "object", "properties": {"quest_id": {"type": "string"}},
                      "required": ["quest_id"]}},
    {"name": "complete_quest", "description": "Mark a quest done and pay out rewards.",
     "input_schema": {"type": "object", "properties": {"quest_id": {"type": "string"}},
                      "required": ["quest_id"]}},
    {"name": "plan_questline", "description": "Turn a big goal into a staged training arc (see M05).",
     "input_schema": {"type": "object", "properties": {"goal": {"type": "string"}},
                      "required": ["goal"]}},
]
python
def lumi_system(bible, player) -> str:
    g = bible.guide
    return (f"You are {g.name}, {g.kind}. Voice: {g.voice}. You are the adventurer's Guide in "
            f"{bible.town}. Tone to keep: {bible.tone_keep}. Never: {bible.tone_avoid}. "
            f"The adventurer is level {player.level}. Help them plan their real day. Use tools to read "
            f"and change the board — never claim you did something without calling the tool. Be brief "
            f"and kind; suggest, don't nag.")

def guide_turn(llm, repo, bible, player, history: list[dict]):
    system = lumi_system(bible, player)
    while True:
        r = llm.act(system, history, TOOLS)
        history.append({"role": "assistant", "content": r.content})
        tool_uses = [b for b in r.content if b.type == "tool_use"]
        if not tool_uses:                                  # Lumi is done acting — return her words
            return next((b.text for b in r.content if b.type == "text"), "")
        results = []
        for tu in tool_uses:
            out = dispatch(repo, bible, player, tu.name, tu.input)   # runs the REAL service fn
            results.append({"type": "tool_result", "tool_use_id": tu.id, "content": out})
        history.append({"role": "user", "content": results})         # feed results back, loop
python
def dispatch(repo, bible, player, name: str, args: dict) -> str:
    # Every tool validates ownership + existence before touching state. The agent cannot
    # complete a quest that isn't the player's, or invent an id — the repo is the authority.
    if name == "list_quests":
        return repo.render(repo.open_quests(player.id, status=args.get("status")))
    if name == "create_quest":
        q = smith(repo.llm, args["task"], bible); repo.add(player.id, q); return f"Added: {q.title}"
    if name == "complete_quest":
        q = repo.get_owned(player.id, args["quest_id"])          # raises if not theirs
        pay = award(player, q); repo.mark_done(q.id); repo.save(player)
        return f"Done: {q.title} (+{pay.xp} XP{', level up!' if pay.leveled_up else ''})"
    ...  # break_down_quest, plan_questline
M05

Training arcs: a big goal becomes a skill-tree questline

Why real goals are too big for one quest  ·  New goal decomposition, a small quest DAG  ·  Output a staged, unlockable arc

“Learn guitar.” “Ship a side project.” “Get comfortable running 5k.” These aren’t quests — they’re training arcs. Lumi decomposes a goal into a small graph of quests with prerequisites: a skill tree the player climbs. Some nodes are required milestones; others are optional side-quests that add colour. This is planning as structured output.

python
from pydantic import BaseModel, Field
from typing import Literal

class ArcNode(BaseModel):
    key: str                                  # "tune", "first-chords", "one-song"
    title: str
    task: str                                 # the concrete real-world action for the Quest-smith
    difficulty: Literal["trivial","easy","normal","hard","epic"]
    kind: Literal["milestone", "optional"]
    requires: list[str] = Field(default_factory=list, description="keys that must be done first")

class TrainingArc(BaseModel):
    goal: str
    title: str = Field(description="a warm name for the arc, e.g. 'The Six-String Road'")
    nodes: list[ArcNode]

ARC_SYS = (
    "You are Lumi, planning a training arc for a real goal. Break it into 5-9 nodes that build on each "
    "other via `requires`. Early nodes must be tiny and winnable (confidence first). Mark load-bearing "
    "steps `milestone`, nice-to-haves `optional`. Each node's `task` must be a concrete real action.")

def plan_arc(llm, goal: str, bible) -> TrainingArc:
    return llm.parse(ARC_SYS, f"Goal: {goal}", TrainingArc)
python
# Persist as rows with prereq edges; a node unlocks when all its `requires` are done.
def unlocked_nodes(arc: TrainingArc, done: set[str]) -> list[ArcNode]:
    return [n for n in arc.nodes if n.key not in done and set(n.requires) <= done]

# The first quest smithed from an arc is always its cheapest unlocked milestone — start with a win.
M06

Habits & adaptive difficulty: the Guide reads the room

Why a habit game must adapt to a real, imperfect human  ·  New recurrence, streaks, history-aware resizing  ·  Output quests that shrink or grow with you

Real habits wobble. A daily that felt easy in week one becomes a wall in a hard week. Questling adapts: it reads your completion history and, when a habit is slipping, Lumi offers a smaller version; when you’re consistently crushing it, she offers to raise the stakes. The deterministic streak logic decides when to adapt; the model decides how the resized quest reads.

python
from datetime import date

class Habit(BaseModel):
    task: str
    cadence: str                 # "daily" | "weekly"
    difficulty: str
    history: list[bool] = []     # most recent last: did they complete each occurrence?

def read_the_room(h: Habit) -> str:
    recent = h.history[-3:]
    if len(recent) == 3 and not any(recent):     return "shrink"     # 3 straight misses
    if len(h.history) >= 5 and all(h.history[-5:]): return "escalate"   # 5 straight wins
    return "hold"
python
RESIZE_SYS = (
    "You are Lumi. The adventurer's habit needs resizing. If 'shrink': propose a KINDER, smaller version "
    "that still counts — the tiniest honest step (a 3km run becomes 'step outside and walk to the mill'). "
    "If 'escalate': propose a slightly bolder version they've earned. Warm, never shaming. One task line.")

def resize(llm, h: Habit, direction: str, bible) -> Quest:
    proposal = llm.complete(RESIZE_SYS, [{"role": "user",
        "content": f"Habit: {h.task!r}. Direction: {direction}. History: {h.history[-5:]}"}])
    return smith(llm, proposal, bible)          # re-smith so it lands as a proper quest
M07

The living world: gacha, boosters, loot — and a town that grows

Why coins and gems need somewhere to matter  ·  New weighted loot, a fair gacha, level-gated unlocks  ·  Output a world that visibly rewards habit

Rewards are only motivating if they buy something. Questling spends coins and gems on a gacha (cosmetic companions and town decorations), boosters (an XP×2 charm, a quest re-roll), and loot rolled on completion. And the village itself grows: buildings unlock as you level, so progress is something you can see on the map, not just a number. Crucially, the odds are code — published and reproducible — and the model only writes the flavor.

python
import random

# Published odds. Cosmetic only — nothing here is pay-to-win, and no real money exists in this build.
GACHA = [
    ("common",    0.70, "a field companion (cosmetic)"),
    ("rare",      0.24, "a lantern-sprite friend"),
    ("legendary", 0.06, "a wanderlight familiar"),
]

def pull(rng: random.Random) -> str:
    roll, acc = rng.random(), 0.0
    for tier, p, _ in GACHA:
        acc += p
        if roll < acc:
            return tier
    return GACHA[-1][0]

# seed the RNG per pull-id so a pull is reproducible and auditable — fairness you can test.
def gacha_pull(player, pull_id: str) -> str:
    if player.gems < 5: raise ValueError("not enough gems")
    player.gems -= 5
    return pull(random.Random(f"{player.id}:{pull_id}"))
python
# The town grows with the player — buildings from the bible's regions unlock by level.
def unlocked_buildings(bible, level: int) -> list[str]:
    return [r.name for r in bible.regions if r.unlocks_at_level <= level]

# The model's ONLY job here: name and describe a newly-pulled companion, in-tone. Not the odds.
FLAVOR_SYS = "You are Lumi. Give a warm 1-line name + blurb for a newly befriended companion of this tier."
def name_companion(llm, tier: str, bible) -> str:
    return llm.complete(FLAVOR_SYS, [{"role": "user", "content": f"Tier: {tier}. Town: {bible.town}."}])
M08

Memory & the quest-letter: turn a week of habits into a story

Why data becomes meaning — the emotional payoff  ·  New bounded memory, grounded narrative recap  ·  Output a warm letter from Lumi

The delight that makes people come back isn’t the XP bar — it’s meaning. When a player finishes an arc or closes out a week, Lumi writes them a quest-letter: a short, warm in-world note that recaps what they actually did, sealed with the Wanderlight. It runs on a compacted journal — a running summary of the adventurer’s arc so memory stays bounded — and it must be grounded in real completed quests, never invented.

python
class Journal(BaseModel):
    player_id: str
    summary: str = ""            # compacted arc-so-far — bounded memory
    recent: list[str] = []       # verbatim recent completions, cleared on compaction

COMPACT_SYS = ("Summarize this adventurer's journey so a Guide can recall it later. Preserve real "
               "milestones reached, habits kept and broken, and the emotional shape of the week. Concise.")

def compact(llm, j: Journal) -> Journal:
    joined = j.summary + "\n" + "\n".join(j.recent)
    j.summary = llm.complete(COMPACT_SYS, [{"role": "user", "content": joined}], max_tokens=600)
    j.recent = []
    return j
python
LETTER_SYS = (
    "You are Lumi, writing a short letter to the adventurer at the close of their week. Warm, sincere, "
    "a little poetic — like a friend who's proud of them. Mention ONLY quests they truly completed "
    "(given below). No fabrication, no guilt about what they missed. Sign off from the Wanderlight.")

def quest_letter(llm, journal: Journal, completed: list[Quest]) -> str:
    facts = "\n".join(f"- {q.title} (from: {q.source_task})" for q in completed)
    ctx = f"Journey so far: {journal.summary}\n\nCompleted this week:\n{facts}"
    return llm.complete(LETTER_SYS, [{"role": "user", "content": ctx}], max_tokens=500)
M09

Integrity & safety: motivate, don’t manipulate

Why a habit app touches real wellbeing  ·  New honor-system completion, tone guards, crisis routing  ·  Output a product with a conscience

This is the milestone that decides what kind of thing you’ve built. A habit game sits close to people’s self-worth, and every mechanic here could be turned into a dark pattern. The design choice running through Questling is explicit: it exists to help the player build real habits and feel good — not to maximize time-in-app.

  • Honor-system completion — you mark your own quests done, optionally with a note. No surveillance; the game trusts the player, because the player is the only one it’s for.
  • Gentle streaks — a broken streak “rests”; it is never framed as failure, and there is no manufactured urgency (“LOSE YOUR STREAK IN 2 HOURS”) to yank people back.
  • Healthy limits — if the player logs an unusual number of quests in a day, Lumi nudges toward rest rather than cheering them on. Rest is a quest too.
  • Crisis routing — a task that signals real distress is never gamified. It bypasses the Quest-smith entirely.
python
CRISIS_SYS = ("Classify if this task text signals self-harm, crisis, or acute distress. Return only "
              "'crisis' or 'ok'. Err toward 'crisis' on ambiguity.")

def intake(llm, task: str, bible) -> Quest | dict:
    if llm.complete(CRISIS_SYS, [{"role": "user", "content": task}]).strip().lower().startswith("crisis"):
        # Do NOT turn distress into a quest. Surface support, warmly and plainly.
        return {"kind": "support", "message": "This sounds heavy. You don't have to carry it alone — "
                "please reach out to someone you trust or a local crisis line. I'm here too.",
                "gamified": False}
    return smith(llm, task, bible)

TONE_BANNED = {"grind", "crush it", "no excuses", "lazy", "failure", "loser"}
def tone_ok(text: str) -> bool:
    return not any(w in text.lower() for w in TONE_BANNED)   # cheap guard on every generated line
M10

Evaluation: is it motivating and fair and on-tone?

Why you can’t tune what you don’t measure  ·  New an LLM-judge rubric, programmatic guards, golden tasks  ·  Output a scored build

Three forces pull against each other: the quests must be motivating (you want to do them), fair (rewards match effort), and on-tone (warm Emberbrook, never grimdark) — while the real task stays legible. You cannot tune the Quest-smith or the economy without scoring all four. Use an LLM judge on the qualitative axes and cheap programmatic checks on the rest.

python
class QuestScore(BaseModel):
    motivation: int      # 1-5: would a tired person actually want to start this?
    tone_fidelity: int   # 1-5: warm, cozy, on-bible — not grim, not corporate
    task_legibility: int # 1-5: is the real task still obvious under the flavor?
    fairness: int        # 1-5: does difficulty match real effort?
    notes: str

JUDGE_SYS = (
    "You are evaluating one quest generated from a real task. Score 1-5 on each axis. Be strict about "
    "task_legibility: if you can't tell what the player must really do, it's a 1. Justify each in a clause.")

def judge(llm, task: str, quest: Quest) -> QuestScore:
    return llm.parse(JUDGE_SYS, f"Real task: {task!r}\n\nQuest: {quest.model_dump_json()}", QuestScore)
  • Programmatic guards — assert the model minted no currency (rewards equal REWARDS[difficulty] exactly); assert source_task is present and non-empty; assert no banned tone word (M09); assert every arc is acyclic.
  • Golden tasks — keep a fixed set of real tasks (a chore, a workout, an errand, a scary admin task) and re-judge them on every prompt change, so a ‘small’ wording tweak that quietly wrecks tone or fairness shows up as a score drop.
M11

Serve & persist: a FastAPI backend and the warm anime-village UI

Why a playable, beautiful app  ·  New REST + streaming, Postgres, prompt caching, a Next.js design system  ·  Output a running Questling

Time to make it real. A thin FastAPI service exposes the loop; Postgres (via SQLModel) holds the board, the economy, and the journal; and a Next.js frontend renders the cozy village you designed for. The single biggest cost lever is prompt caching the world bible — it’s identical on every call.

python
from fastapi import FastAPI
from fastapi.responses import StreamingResponse

app = FastAPI()

@app.post("/tasks")                  # smith a task into a quest and add to the board
def add_task(player_id: str, task: str) -> Quest: ...

@app.get("/board")                   # the player's quests, grouped by type
def board(player_id: str) -> list[Quest]: ...

@app.post("/quests/{qid}/complete")  # mark done, apply the ledger, return the payout
def complete(player_id: str, qid: str) -> Payout: ...

@app.post("/guide")                  # stream a turn of conversation with Lumi (tool-calling)
def guide(player_id: str, message: str):
    return StreamingResponse(guide_stream(player_id, message), media_type="text/plain")

@app.post("/arcs")                   # plan a training arc from a goal
def arc(player_id: str, goal: str) -> TrainingArc: ...

Cache the bible — it’s the same on every call

python
def cached_system(bible_text: str, volatile: str) -> list[dict]:
    return [
        {"type": "text", "text": QUESTSMITH_RULES},                                 # stable rules
        {"type": "text", "text": bible_text, "cache_control": {"type": "ephemeral"}}, # cached prefix
        {"type": "text", "text": volatile},                                         # per-call context
    ]
    # Keep the bible BEFORE the breakpoint (byte-stable), the player-specific bits after it.
    # Watch usage.cache_read_input_tokens — if it's zero, a byte is changing in your prefix.

The village UI — a warm, colorful Next.js front end

The design system is the opposite of a dark dungeon: soft golden-hour OKLCH warmth, rounded cards, a hand-drawn village map, and never a neon edge. Here is the token seed and the two components that carry the look — the quest card and the Guide dock.

css
/* app/globals.css — the Questling palette (warm, cozy, anime-village) */
:root{
  --paper:  oklch(97% 0.02 85);     /* sun-warmed parchment    */
  --sky:    oklch(93% 0.05 230);    /* soft morning sky        */
  --meadow: oklch(88% 0.09 145);    /* gentle green            */
  --ember:  oklch(70% 0.16 45);     /* the coral-persimmon accent */
  --gold:   oklch(80% 0.13 85);     /* coins                   */
  --gem:    oklch(72% 0.15 350);    /* gems (warm rose)        */
  --ink:    oklch(30% 0.03 60);     /* soft brown-black text   */
  --radius: 16px;                   /* everything is rounded, nothing is sharp */
}
/* golden-hour, not grimdark: high lightness, low chroma backgrounds, one warm accent. */
tsx
// components/QuestCard.tsx — the atom of the board
export function QuestCard({ q, onComplete }: { q: Quest; onComplete: () => void }) {
  return (
    <article className="qcard">
      <header>
        <span className="qtype">{q.type} quest</span>
        <span className="diff">{q.difficulty}</span>
      </header>
      <h3>{q.title}</h3>
      <p className="flavor">{q.flavor}</p>
      <p className="src">✎ from: {q.source_task}</p>   {/* the real task, always visible */}
      <footer className="rewards">
        <Pill kind="xp">★ {q.xp}</Pill>
        <Pill kind="coin">● {q.coins}</Pill>
        <button className="claim" onClick={onComplete}>Complete</button>
      </footer>
    </article>
  );
}
tsx
// components/GuideDock.tsx — Lumi, always a tap away; streams her reply as she acts
export function GuideDock({ playerId }: { playerId: string }) {
  const [log, setLog] = useState<Msg[]>([]);
  async function send(text: string) {
    setLog((l) => [...l, { who: "you", text }]);
    const res = await fetch("/api/guide", { method: "POST", body: JSON.stringify({ playerId, text }) });
    const reader = res.body!.getReader();                       // stream Lumi's words in
    let acc = "";
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      acc += new TextDecoder().decode(value);
      setLog((l) => [...l.filter((m) => m.who !== "lumi-live"), { who: "lumi-live", text: acc }]);
    }
  }
  return <aside className="guide-dock">{/* map-corner lantern that opens the chat with Lumi */}</aside>;
}

The screens that make it a world, not a to-do list: an isekai onboarding (you are summoned; Lumi greets you and hands your first quest-letter), the Emberbrook map (buildings unlock as you level), the Wanderlight board (quests as cards), the Guide dock (Lumi, one tap away), and the adventurer panel (level, purse, companions, arcs).

A

Worked example: a Tuesday in Emberbrook

Optional three real tasks, one day, end to end

Concrete makes it click. It’s an ordinary Tuesday. You open Questling and add three things off your real list. Here is how the whole machine turns them into a day worth playing.

text
YOU ADD (plain tasks)                  QUEST-SMITH (M02)                 LEDGER (M03)
  "go for a 3km run"          ──▶  "The Three-Kilometre Trail" [normal]   30 XP · 18c · 2g
  "email the dentist"         ──▶  "A Missive to the Toothwright" [easy]  15 XP ·  8c · 1g
  "read 20 pages of DDIA"     ──▶  "Pages of the Deep Codex" [normal]     30 XP · 18c · 2g

YOU ASK LUMI (M04):  "low energy today, where do I start?"
  Lumi → list_quests → reasons over difficulty → "Start with the Toothwright's missive —
         it's small, and it clears a worry. The trail will still be there when you've eaten."

YOU COMPLETE 2  (skip the run — a hard day)
  ledger: +45 XP, +26 coins, +3 gems.  streak: kept (2 of 3 is a good day).  → level 7 → 8!
  the run's habit history logs a miss; if it misses twice more, M06 offers a kinder version.

CLOSE OF DAY (M08):  Lumi's quest-letter
  "Dear friend — you wrote to the toothwright and walked the deep codex a little further today.
   The trail waits, unhurried, and so do I. Rest well. — Lumi, of the Wanderlight"

Nothing here was grand. Two small real tasks got done, one got gently deferred, and the day felt like progress instead of a list you didn’t finish. That is the entire thesis of Questling in one Tuesday: the mundane, made warm enough to actually do — with a Guide who’s on your side.