Tutorial: Quiz Bot
We'll build a trivia bot with a reusable question subflow and a shared leaderboard. By the end you'll have a working /play round and a /top board. This is based on the quiz.botgami blueprint.
What you'll learn
- A reusable
subflowthat asks and grades a question branchonask.textwith timeout handling- A
shared.*leaderboard with the upsert pattern - Named menus for option buttons + typed answer payloads
- Sorting and rendering a top-N board
Step 1: State
bot QuizBot {
meta { name: "Quiz Bot" slug: "quiz-bot" icon: "🧠" }
// The question bank — editable in the Variables panel.
shared questions: Array<{ question: String, answer: String, points: Number }> = [
{ question: "Capital of Japan?", answer: "Tokyo", points: 10 },
{ question: "2 + 2 * 2?", answer: "6", points: 10 },
{ question: "Chemical symbol for water?", answer: "H2O", points: 10 }
]
// Cross-user leaderboard.
shared leaderboard: Array<{ uid: Number, name: String, score: Number }> = []
// Per-user score.
user score: Number = 0
}
Step 2: The reusable question subflow
This asks one question, grades it, and updates user.score. Putting it in a subflow means /play can call it in a loop.
subflow ask_question(q: { question: String, answer: String, points: Number }) {
reply = ask.text(
q.question,
timeout: 60,
on_timeout => { send.text("⏱ Time's up on that one.") },
on_cancel => { send.text("Quiz cancelled.") }
)
when str.lower(str.trim(reply)) == str.lower(str.trim(q.answer)) {
set { user.score += q.points }
send.text(`✅ Correct! +${q.points} (total ${user.score})`)
} else {
send.text(`❌ Wrong — the answer was ${q.answer}.`)
}
return {}
}
Note how we normalize both sides with str.lower(str.trim(...)) before comparing.
Step 3: Play a round
/play runs the first three questions, then upserts the player onto the leaderboard. A named menu shows the post-round actions — no callback wiring needed.
menu roundMenu {
text "🏆 Leaderboard" -> showTop()
text "🔁 Play again" -> startRound()
}
flow play on command "/play" {
await startRound()
}
subflow startRound() {
set { flow.round = array.slice(shared.questions, 0, 3) }
foreach q in flow.round {
await ask_question(q)
}
// Upsert onto the shared leaderboard.
set {
shared.leaderboard = array.reject(shared.leaderboard, "uid", user.id)
shared.leaderboard << [{ uid: user.id, name: user.first_name, score: user.score }]
}
send.text(`🏁 Round complete! Your score: ${user.score}`, reply_markup: roundMenu)
}
Step 4: The leaderboard
Sort descending, take the top 10, and render with foreach + index. Always handle the empty case. The showTop subflow is shared between the /top command and the Leaderboard button in the menu — no duplicate flows needed.
flow top on command "/top" {
await showTop()
}
subflow showTop() {
when util.len(shared.leaderboard) == 0 {
send.text("🏆 No scores yet — be the first to /play!")
} else {
set { flow.ranked = array.slice(array.sortBy(shared.leaderboard, "score", true), 0, 10) }
set { flow.body = "🏆 Leaderboard\n\n" }
foreach e, i in flow.ranked {
set { flow.pos = i + 1 }
set { flow.body = flow.body + `${flow.pos}. ${e.name} — ${e.score}\n` }
}
send.text(flow.body, reply_markup: roundMenu)
}
return {}
}
The complete bot
bot QuizBot {
meta { name: "Quiz Bot" slug: "quiz-bot" icon: "🧠" }
shared questions: Array<{ question: String, answer: String, points: Number }> = [
{ question: "Capital of Japan?", answer: "Tokyo", points: 10 },
{ question: "2 + 2 * 2?", answer: "6", points: 10 },
{ question: "Chemical symbol for water?", answer: "H2O", points: 10 }
]
shared leaderboard: Array<{ uid: Number, name: String, score: Number }> = []
user score: Number = 0
// Named menu — appears after a round completes and after the leaderboard.
menu roundMenu {
text "🏆 Leaderboard" -> showTop()
text "🔁 Play again" -> startRound()
}
subflow ask_question(q: { question: String, answer: String, points: Number }) {
branch ask.text(q.question, timeout: "60s") {
"timeout" { send.text("⏱ Time's up on that one.") }
"error" { send.text("Something went wrong.") }
default {
when str.lower(str.trim(answer)) == str.lower(str.trim(q.answer)) {
set { user.score += q.points }
send.text(`✅ Correct! +${q.points} (total ${user.score})`)
} else {
send.text(`❌ Wrong — the answer was ${q.answer}.`)
}
}
}
return {}
}
subflow startRound() {
set { flow.round = array.slice(shared.questions, 0, 3) }
foreach q in flow.round {
await ask_question(q)
}
set {
shared.leaderboard = array.reject(shared.leaderboard, "uid", user.id)
shared.leaderboard << [{ uid: user.id, name: user.first_name, score: user.score }]
}
send.text(`🏁 Round complete! Your score: ${user.score}`, reply_markup: roundMenu)
return {}
}
subflow showTop() {
when util.len(shared.leaderboard) == 0 {
send.text("🏆 No scores yet — be the first to /play!")
} else {
set { flow.ranked = array.slice(array.sortBy(shared.leaderboard, "score", true), 0, 10) }
set { flow.body = "🏆 Leaderboard\n\n" }
foreach e, i in flow.ranked {
set { flow.pos = i + 1 }
set { flow.body = flow.body + `${flow.pos}. ${e.name} — ${e.score}\n` }
}
send.text(flow.body, reply_markup: roundMenu)
}
return {}
}
flow start on command "/start" {
send.text("🧠 Welcome to Quiz Bot! Send /play to start a round.")
}
flow play on command "/play" { await startRound() }
flow top on command "/top" { await showTop() }
}
Where to take it next
The full quiz.botgami blueprint adds categories, streaks with a combo multiplier, a coins economy with paid hints, a daily group challenge, and a weekly season reset. See Cross-user data and Scheduled & broadcast.