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
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”
Foundations: the model seam, config, structured outputs
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.
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.
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:16app/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.
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 turnimport 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")The world bible: Emberbrook, the Wanderlight guild, and Lumi’s voice
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.
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")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)The Quest-smith: turn a mundane task into a warm quest
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.
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).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
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”
The economy: XP, coins, gems, levels — and why the model never mints them
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.
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 lvldef 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
Lumi, the Guide: a tool-calling agent that runs the board
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.
# 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"]}},
]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, loopdef 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_questlineTraining arcs: a big goal becomes a skill-tree questline
“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.
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)# 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.Habits & adaptive difficulty: the Guide reads the room
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.
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"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 questThe living world: gacha, boosters, loot — and a town that grows
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.
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}"))# 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}."}])Memory & the quest-letter: turn a week of habits into a story
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.
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 jLETTER_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)Integrity & safety: motivate, don’t manipulate
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.
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 lineEvaluation: is it motivating and fair and on-tone?
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.
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); assertsource_taskis 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.
Serve & persist: a FastAPI backend and the warm anime-village UI
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.
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
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.
/* 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. */// 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>
);
}// 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).
Worked example: a Tuesday in Emberbrook
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.
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.