A WhatsApp bot that runs YAML-defined walking-tour quests. Authors write a quest in a single YAML file, send it to the bot, and players unlock stops by answering clues one at a time in a WhatsApp chat.
Built on FastAPI + Twilio's WhatsApp sandbox + SQLite. The game engine is pure Python — transport-free — so swapping WhatsApp for Telegram, SMS, or a web UI is a one-file adapter.
Player: /start sf
Bot: Welcome to the SF demo quest! Two stops, no walking required.
Bot: I stand on Telegraph Hill, 210 feet tall, shaped like the nozzle
of a firefighter's hose. What am I?
Player: coit tower
Bot: 🔥 Yes! Lillie Hitchcock Coit left $125k to the city to build it in 1933.
Bot: One block of my road has eight sharp hairpin turns down a steep hill.
Tourists line up to drive me. What street am I?
Player: /hint
Bot: 💡 Hint: It's often called the crookedest street in the world.
Player: lombard
Bot: 🛣️ Exactly — the 600 block of Lombard between Hyde and Leavenworth.
Bot: 🎉 You finished the demo.
- Single-file quests. One YAML file describes the entire game — stops, clues, accepted answers, hints, and success messages. Validated against a published JSON Schema.
- In-chat authoring. Authors send a YAML file (as
.txtdocument or pasted into the chat body) and the bot stores it immediately. No redeploy. - Forgiving answer matching. Case-insensitive, punctuation-stripped membership against a list of accepted answers per stop.
- Progressive hints. Manual (
/hint) or auto-nudged after N wrong guesses per stop — configurable per stop in the YAML. - Resumable sessions. Players can walk away, come back hours later, and
/startpicks up where they left off. - Admin commands. An allowlist of phone numbers can
/skip,/goto <stop_id>,/whereami, and/dumpstatefor live debugging. - Webhook signature verification. Every Twilio request is validated with
X-Twilio-Signaturebefore the engine runs. - 62 tests, fully transport-agnostic engine. Engine never imports from transport; tests exercise the engine with an in-memory SQLite.
Requires Python 3.12+ and uv.
git clone https://github.com/noncuro/cityquest.git
cd cityquest
make install # uv sync
cp .env.example .env # then fill in Twilio credentials (see below)
make schema # regenerate quest.schema.json from the Pydantic models
make test # 62 passing- Sign up for a free Twilio account and enable the WhatsApp Sandbox.
- Copy your Account SID and Auth Token into
.env. - Join your sandbox by sending the
join <code>message from WhatsApp to the sandbox number (Twilio shows both). - Put your own WhatsApp number in
ADMIN_WA_NUMBERS(E.164 format, e.g.+14155551234) if you want access to admin commands.
Twilio needs a public HTTPS URL to POST player messages to. For local dev, ngrok is easiest:
# Terminal 1
make dev # uvicorn on :8000
# Terminal 2
NGROK_DOMAIN=your-subdomain.ngrok-free.app make tunnelSet PUBLIC_BASE_URL in .env to match your ngrok URL (signature verification
uses it), then in the Twilio Sandbox settings set the incoming webhook to:
https://your-subdomain.ngrok-free.app/webhook/twilio
From WhatsApp, send:
/quests # list available quests
/start sf # play the included San Francisco demo
/help # full command list
A quest is one YAML file. The bot exposes its schema at
GET /quest.schema.json, and quests/sf.yaml is a minimal working example.
Put this as the first line of your YAML so VS Code gives you autocomplete and live validation:
# yaml-language-server: $schema=https://your-subdomain.ngrok-free.app/quest.schema.jsonThen the shape is:
id: sf # short slug used for /start <id>
title: "San Francisco Demo"
city: "San Francisco"
author: "Your Name"
description: "A tiny demo quest."
intro: | # sent when the player runs /start <id>
Welcome to the quest!
Type /hint if you get stuck.
completion: | # sent after the last stop is solved
🎉 You finished.
stops:
- id: coit
name: "Coit Tower warmup" # shown in /status, never to the player
clue: |
I stand on Telegraph Hill, 210 feet tall, shaped like the nozzle
of a firefighter's hose. What am I?
answers: # any one matches (case/punctuation-insensitive)
- "coit tower"
- "coit"
hints:
- "It was funded by a woman who loved firefighters."
- "The first word rhymes with 'exploit'."
auto_hint_after_wrong: 3 # nudge "Stuck? Try /hint." after 3 wrong guesses
success_message: | # optional — fallback is "✅ Correct!"
🔥 Yes! Lillie Hitchcock Coit left $125k to build it in 1933.Required fields: id, title, city, author, description, intro,
completion, and stops[].{id,name,clue,answers}. Everything else has sensible
defaults.
Three ways, any of them works:
- Paste the YAML into a WhatsApp message. If the body contains
stops:andid:on their own lines, the bot parses it as a quest. - Attach as a
.txtdocument. WhatsApp's document picker rejects.yaml, so rename before sending. Twilio delivers it as media, the bot fetches and saves it. - Seed from disk. Put the YAML in
quests/and runuv run python scripts/seed_quests.pyto upsert every file into the DB.
The bot replies with ✅ Quest saved: <title>. Type /start <id> to play.
Player:
| Command | Effect |
|---|---|
/start <quest_id> |
Begin a quest from its first stop |
/start |
Resume your most recent incomplete quest |
/hint |
Reveal the next hint for the current stop |
/status |
Show current stop, wrong guesses, hints used |
/reset |
Wipe your current session |
/quests |
List every available quest |
/schema |
Get the JSON Schema URL for authoring |
/help |
Full command list (admin commands shown if authorized) |
Admin (requires phone number in ADMIN_WA_NUMBERS):
| Command | Effect |
|---|---|
/skip |
Skip the current stop |
/goto <stop_id> |
Jump to a specific stop |
/whereami |
Show current stop with accepted answers and hints |
/dumpstate |
Full session state as JSON |
Any other text is treated as an answer to the current stop.
app/
├── engine/ # pure game logic, no transport imports
│ ├── models.py # Pydantic Quest / Stop
│ ├── actions.py # PlayerInput, BotAction (SendText, ...)
│ ├── matcher.py # case-fold, strip-punct, membership match
│ ├── hints.py # reveal next, auto-nudge
│ ├── runner.py # QuestRunner: start, answer, commands, upload
│ ├── quests.py # YAML -> Quest loader
│ └── export_schema.py # Pydantic -> quest.schema.json
├── store/ # SQLModel
│ ├── models.py # QuestRow, Session, Event
│ └── db.py # engine + session factory
├── transport/
│ └── twilio_whatsapp.py # parse webhook, verify sig, dispatch
├── config.py # pydantic-settings
└── main.py # FastAPI app factory + webhook route
Key design choice: the engine operates on PlayerInput → list[BotAction]
with no knowledge of HTTP or Twilio. Adding a new transport (Telegram, SMS,
web UI) means writing one adapter file that parses incoming requests into
PlayerInput and dispatches the returned actions. The TwilioDispatcher
splits any SendText on \n\n into multiple sequential messages so authors
can compose paragraph-style replies naturally.
Session IDs are opaque tokens — wa:+14155551234 for the Twilio adapter —
which keeps the engine transport-agnostic while still allowing per-user state.
make test # run the test suite
make dev # uvicorn with --reload on :8000
make schema # regenerate quest.schema.json after changing engine/models.pyThe full implementation plan used to build this (TDD, 17 tasks, subagent-driven)
lives in docs/superpowers/plans/.
- Fuzzy / semantic answer matching (currently exact-membership only)
- Team sessions (shared state across multiple players)
- Telegram transport
- Production hosting guide (nginx + Let's Encrypt)
- Author-facing authoring UI
MIT — see LICENSE.