How I Built the Smart Quiz Plugin for coob (and Why It Changed Our Plugin State Pattern)
I work as a programmer in an online education school team.
At some point we had a very basic quiz flow. It worked, but it was painful:
- teachers wanted faster quiz creation
- students wanted better UX on mobile
- we needed progress restore (people leave Telegram/browser tabs all the time)
- product wanted passing score, retry, feedback, navigation, and "please do not break old courses"
So I built a new plugin: Smart Quiz.
This post is not a marketing story. It is a practical dev tutorial about how I built plugins/smart-quiz, how it works in coob, and how this plugin pushed us to use a cleaner shared plugin state pattern (editor state + user state + handler contract).
I will keep the language simple and a bit "dev team chat" style.
What Smart Quiz Had To Solve
We needed one plugin that could cover most quiz cases without making teachers open 10 different components.
The final plugin supports:
- multiple questions
- answer shuffling
- progress bar
- question navigation
- detailed feedback
- passing score
- retry
- progress persistence
- English/Russian UI
You can see this reflected directly in the plugin files:
manifest.jsondefines the plugin entry pointsdist/state.jsondefines the main component state shapedist/settings.jsondefines teacher-facing settings (JSON Schema)dist/handler.luachecks final result and returns pass/fail messagesrc/edit/edit.jsis the teacher editorsrc/view/view.jsis the student runtime
That file split is exactly what coob plugin docs recommend in the Plugin Quickstart Guide.
The Core Idea: One Plugin, Three State Layers
This is the main thing that made Smart Quiz stable.
I stopped thinking "quiz UI" and started thinking "state contract".
In practice the plugin uses 3 layers:
- Component state (
state.json+ editor save)
This is what the teacher configures: title, questions, answers, correct options. - User state (
saveUserState)
This is what the student is doing right now: current question, selected answers, progress. - Submission state (
before_submit-> Lua handler)
This is the final payload we send for evaluation (results, percentage, etc.).
When these three layers are clean, the plugin is easy to debug and extend.
This pattern later became the way I think about most coob trainer plugins.
Step 1: Start With the Plugin Contract (manifest.json)
Per coob docs, every plugin needs a manifest.json with entry files. Smart Quiz uses:
"entry": {
"state": "./dist/state.json",
"handler": "./dist/handler.lua",
"settings": "./dist/settings.json",
"edit": "./dist/edit.html",
"view": "./dist/view.html",
"import": "./dist/import_example.txt"
}
This already tells coob everything it needs:
- where the editor UI lives
- where the student UI lives
- how to build the teacher settings form
- how to validate/check results server-side (Lua handler)
- optional import format example
Also note settings.answerRequired = true in the manifest. That matters because Smart Quiz is a trainer plugin, not just a viewer.
Step 2: Define the Main Component State First (dist/state.json)
Before writing UI, I defined a minimal state shape:
{
"quizTitle": "",
"quizzes": [
{
"id": 1,
"question": "",
"options": [],
"correctAnswer": -1
}
],
"currentQuestionIndex": 0,
"totalQuestions": 1
}
This is a super useful trick:
- UI can change many times
- styles can change
- features can grow
But if your state shape is stable, the plugin remains manageable.
The coob docs also describe state.json as the plugin state definition, so this is the right place to lock your data model early.
Step 3: Put Teacher Controls Into settings.json (Not Into Quiz Data)
A common mistake is mixing quiz content and plugin behavior in one object.
I did not want that.
So quiz content goes to component state (quizzes, quizTitle), and behavior flags go to dist/settings.json, which coob renders as a form from JSON Schema.
Examples from Smart Quiz settings:
shuffleOptionsshowFeedbackallowRetryshowProgressquestionNavigationpassingScorecompletedMessages.success/wrong
Why this split is good:
- teachers can reuse the same quiz data with different behavior
- handler logic reads settings in one place (
bx_state.component._settings) - the view code becomes cleaner (
$_bx.getSettings())
This is one of those "small architecture wins" that saves you later.
Step 4: Build the Teacher Editor (src/edit/edit.js)
The editor is where teachers create questions, options, and correct answers.
What I used from $_bx in edit mode
Smart Quiz editor relies on the core plugin API:
$_bx.onReady(...)$_bx.get(...)$_bx.event().on("before_save_state", ...)$_bx.onImportData(...)$_bx.showErrorMessage(...)$_bx.showSuccessMessage(...)
This matches the coob $_bx docs and the advanced plugin UX docs (import + state hooks).
The important part: before_save_state
Instead of trusting the UI state blindly, I validate and normalize data right before coob saves plugin state.
Why this matters:
- teachers may leave empty options
- imported content may be incomplete
- UI bugs happen
before_save_state is the last clean checkpoint.
In Smart Quiz, I use this moment to ensure the saved state has the right fields and to keep submit controls consistent.
Import support ($_bx.onImportData)
This was a big quality-of-life feature for teachers.
We added import parsing so a teacher can paste a plain-text structure and get quiz questions generated automatically.
Why this is worth it:
- course teams often already have quiz banks in docs/spreadsheets
- manual clicking in UI is slow
- import makes the plugin feel "production ready", not just demo-ready
If you build plugins for real educators, import is not a bonus feature. It is a time saver they remember.
Step 5: Build the Student Runtime (src/view/view.js)
This file is where most of the logic lives.
The main job is simple:
- render questions
- track answers
- restore progress
- submit final result
The implementation is not small, but the flow is clean if you keep state responsibilities clear.
$_bx calls I used in view mode
Smart Quiz uses these a lot:
$_bx.onReady(...)$_bx.get("quizTitle")$_bx.get("quizzes")$_bx.getState()$_bx.getSettings()$_bx.saveUserState(...)$_bx.answerSubmit()$_bx.event().on("before_submit", ...)$_bx.language()$_bx.getComponentID()
This gives us:
- plugin config from coob state
- feature flags from settings
- user progress persistence
- submission lifecycle hooks
- language-aware UI and stable identifiers
Step 6: Save Progress Early and Often (saveUserState)
This is where Smart Quiz stopped feeling like a toy plugin.
Students do this all the time:
- open lesson
- answer 3 questions
- switch app
- come back later
If progress is lost, they get angry. Fair.
So Smart Quiz saves user progress during interaction (navigation/answer changes), not only at the end.
That progress includes things like:
- current question index
- selected answers
- answered questions
- total questions
- result flags (when relevant)
The coob docs describe this pattern in the advanced UX docs (progress tracking), and it maps perfectly to quizzes.
Step 7: Use before_submit to Build the Final Payload
This is the second key architectural move.
I do not send random UI-local state to the handler.
Before calling $_bx.answerSubmit(), Smart Quiz hooks into:
$_bx.event().on("before_submit", (v) => {
// Put normalized quiz result data into v.state
// Example: results, percentage, answers, meta
});
Why this is good:
- the Lua handler receives predictable data
- you can refactor UI without breaking evaluation
- debugging gets much easier ("what exactly did we submit?")
In Smart Quiz, the view calculates results (correct count, percentage, etc.), then writes them into the submission state right before submit.
That is the bridge between JS UI and Lua logic.
Step 8: Keep the Lua Handler Small (dist/handler.lua)
A lot of teams put too much logic into Lua handlers.
For Smart Quiz I intentionally kept handler.lua tiny:
- read settings (
bx_state.component._settings) - read computed results from request (
bx_state.request.results) - compare
percentagewithpassingScore - return
true/falseand the message
This is exactly what a handler should do in this case: final decision, not UI orchestration.
The actual Smart Quiz handler is basically:
local settings = bx_state.component._settings or {}
local results = bx_state.request.results or { percentage = 0 }
if (results.percentage or 0) >= (settings.passingScore or 70) then
return true, "success message"
end
return false, "wrong message"
Simple and solid.
And it follows the coob plugin docs model where bx_state contains:
bx_state.component(plugin state)bx_state.component._settings(plugin settings)bx_state.request(student submission)
Step 9: Add Features Through Settings Flags, Not Branch Hell
Smart Quiz has a lot of optional behavior:
- shuffle
- progress bar
- question navigation
- feedback
- retry
- passing score threshold
If you hardcode all that, view.js turns into spaghetti.
The cleaner approach is:
- define flags in
settings.json - read them via
$_bx.getSettings() - wrap UI behavior in small helper checks
That is how Smart Quiz stays configurable without becoming unreadable.
This is also what made it reusable across different courses in our school:
- exam mode: strict navigation, high passing score
- practice mode: retry on, feedback on
- quick check mode: minimal feedback, no retry
Same plugin. Different settings.
Step 10: Add Internationalization Early (Even If You Only Need One Language Today)
Smart Quiz supports English and Russian.
At first I thought "we only need one language for this course, I can patch later."
Bad idea.
Quiz buttons and system messages appear everywhere. If you postpone i18n, you will later touch every render path.
Smart Quiz uses $_bx.language() and translation dictionaries in the view layer. This was cheap to add early and expensive to add late.
If you build plugins for schools, teams, or marketplaces, add i18n early.
What Changed in Our Team After Smart Quiz
This plugin was not just "one more quiz component".
It forced us to standardize a repeatable pattern for trainer plugins in coob:
state.json= component data model (teacher content)settings.json= behavior switches (teacher config)view.js= runtime UX + user progress persistencebefore_submit= normalized submission payloadhandler.lua= final pass/fail decision
Once we started using this pattern, new plugins became easier to build and safer to update.
This is what I mean by "it became part of the common plugin state approach" in our work: not magic framework changes, but a clean state contract we can reuse.
Practical Tips If You Want to Build Your Own coob Plugin
Here is the short version from the trenches:
- Start with
state.jsonbefore UI - Keep content and settings separate
- Use
before_save_statein editor to validate/normalize - Use
saveUserStatefor real progress (not just final submit) - Use
before_submitto send normalized result data - Keep
handler.luasmall and deterministic - Add import early if teachers will create a lot of content
- Test mobile flow first (students mostly use phones)
A Minimal Build Plan You Can Reuse
If I had to build Smart Quiz again from zero, I would do it in this order:
- Create
manifest.json+ entry points - Define
dist/state.json - Define
dist/settings.json(JSON Schema) - Build
src/edit/edit.jswith save validation - Build
src/view/view.jswith local runtime state - Add
saveUserState/ restore flow - Add
before_submitpayload normalization - Add tiny
dist/handler.lua - Add import support
- Add i18n and polish
This order keeps risk low and gives you a working plugin early.
Final Thoughts
Smart Quiz started as "we need a better quiz by next sprint".
It ended up being a useful plugin and a better engineering pattern for how we build coob trainer plugins.
If you are building plugins for online education, the biggest win is not flashy UI. It is a clean state contract between:
- teacher editor
- student runtime
- submission handler
Get that right, and features become much easier to ship.
Useful coob Docs (the same docs I used while building this)
- Plugin Quickstart Guide
- Plugin Introduction
- $_bx Library Documentation
- Advanced User Experience Features for Plugins
If you want, I can write a follow-up post with a smaller code example (for example, a mini flashcards plugin) using exactly the same state pattern.