{% set api_key = user.get('api_key') if user else 'YOUR_API_KEY' %}
# Quizzes A quiz on DevPlace is user-generated content, exactly like a gist or a project: it has an owner, a title, a description, comments, votes, bookmarks and reactions. Any signed-in member writes quizzes, every member plays them, and guests read published ones. Open the hub at `{{ base }}/quizzes`. It is one page with three columns: filters on the left, the quiz list in the middle showing what you still have **to do** and what you already **completed** with your score, and the cross-quiz **scoreboard** on the right. ## Creating a quiz There are three ways to make one, and they all end at the same place: 1. **New quiz** on the hub opens a small form (title, description, settings) and creates a draft. You then add its questions in the builder. 2. **Create quiz with Devii** opens the assistant with a request already typed in. Devii asks you for the topic, the number of questions and the difficulty, builds the whole quiz in one call, and shows you the result. It never sends the message for you and it never publishes without asking. 3. **The API**, by POSTing one JSON document to `/quizzes/import`. This is the same call Devii makes. ## Publishing is permanent A draft is fully editable. The moment you publish it, the quiz, its questions and its options are **frozen forever**. There is no unpublish, no post-publish edit and no administrator override. The only operation left is deleting it. This is deliberate. It is what makes a score comparable: two members who answered the same published quiz answered exactly the same questions, so the scoreboard is honest. Before you press Publish, check: - Every question has the prompt you meant to write. - Every single-choice question has exactly one correct option, and every multiple-choice question has at least one. - Every free-text question has a reference answer or grading criteria. - The time limit and the pass mark are what you want. The builder shows this as a live checklist and the Publish button stays disabled until the list is empty, so you cannot freeze something broken by accident. ## The eight question kinds | Kind | What the player sees | How it is graded | |------|----------------------|------------------| | `single_choice` | Radio options | The chosen option is the correct one | | `multiple_choice` | Checkbox options | Partial credit: correct picks minus wrong picks, never below zero | | `true_false` | Two large buttons | The chosen boolean matches | | `free_text` | A textarea | The AI reviewer, against your criteria. Partial credit | | `fill_blank` | One input per blank | Per blank, whitespace normalized, case sensitivity is your choice | | `numeric` | A number input | Within the tolerance you set | | `ordering` | One selector per position | The longest correct run from the start | | `matching` | A selector per left-hand item | Per pair | Ordering and matching use plain selectors plus keyboard controls, never a drag-only interaction, so they work with a keyboard and a screen reader. ## How free-text grading works A free-text answer goes to the platform's own AI reviewer, together with your reference answer and your grading criteria. The reviewer returns a score between 0 and 1, a short piece of feedback for the learner, and a confidence. The score is re-clamped on the server and the correct/incorrect verdict is derived from the clamped score, so a reviewer cannot mark an answer correct while scoring it zero. The answer text is treated as data, never as instructions. The call is billed to the **answering member's** own API key, so it shows up in that member's AI usage exactly like any other AI feature they use. When the reviewer is unavailable - it is down, it times out, or it returns something unreadable - the answer is **still graded**, by a deterministic comparison of the keywords in your reference answer against the learner's answer. That answer is stamped `graded_by: "fallback"` and both the player's screen and the JSON say so. Grading never silently becomes a zero. ## Settings | Setting | What it changes | |---------|-----------------| | Shuffle questions | Each attempt gets its own question order, stable for that attempt | | Shuffle options | The answer options are reordered per attempt | | Reveal answers | After a question is answered, its correct answer is shown | | Allow review | The results screen lists every answer with its feedback | | Time limit | Seconds from the moment the attempt starts. 0 means no limit | | Pass mark | Percentage needed for a pass verdict. 0 means no verdict | The time limit is a deadline stored on the attempt. Nothing runs in the background: when the deadline has passed, the attempt reads as expired the next time anyone looks at it and refuses further answers. ## Playing Starting a quiz creates an attempt. You get **one** in-progress attempt per quiz: pressing Start again resumes the one you already have, on any device and in any tab, because the attempt lives on the server and never in your browser. Each question can be answered exactly once. Submitting the same question twice is refused and credits nothing. When you finish, the score is recomputed from your recorded answers, so it is always exactly the sum of what you earned. ## The scoreboard The rail on the hub ranks members across every published quiz. Two rules make it honest: - **Your best attempt per quiz counts, never the sum of your attempts.** Replaying a quiz can raise your contribution up to your personal best and never beyond it, so grinding one easy quiz is worth nothing. - **Quizzes you wrote yourself count too.** Playing your own quiz scores like any other, and your **Your progress** card shows exactly the same figures the board ranks you by. Ties are broken by the number of quizzes completed and then deterministically, so the board never reshuffles between two page loads. ## Driving a quiz from a script Everything above is available over the JSON API. This client imports a quiz, publishes it, plays it end to end and prints the result, using only the standard library. ```python import json import urllib.request BASE = "{{ base }}" API_KEY = "{{ api_key }}" def call(method, path, fields=None): data = urllib.parse.urlencode(fields, doseq=True).encode() if fields else None request = urllib.request.Request(f"{BASE}{path}", data=data, method=method) request.add_header("Authorization", f"Bearer {API_KEY}") request.add_header("Accept", "application/json") if data: request.add_header("Content-Type", "application/x-www-form-urlencoded") with urllib.request.urlopen(request) as response: return json.loads(response.read()) document = { "title": "SQLite fundamentals", "description": "Three questions on WAL and indexing.", "settings": {"reveal_answers": True, "pass_percent": 60}, "questions": [ { "kind": "single_choice", "prompt": "Which journal mode allows concurrent readers and one writer?", "points": 1, "options": [{"label": "DELETE"}, {"label": "WAL", "is_correct": True}], }, { "kind": "true_false", "prompt": "A partial index can carry a WHERE clause.", "points": 1, "correct_boolean": True, }, { "kind": "numeric", "prompt": "How many bytes are in a kibibyte?", "points": 1, "numeric_value": 1024, }, ], } created = call("POST", "/quizzes/import", {"document": json.dumps(document)}) slug = created["data"]["slug"] call("POST", f"/quizzes/{slug}/publish", {"confirm": "true"}) started = call("POST", f"/quizzes/{slug}/attempts") attempt_uid = started["data"]["uid"] attempt = call("GET", f"/quizzes/{slug}/attempts/{attempt_uid}") for question in attempt["attempt"]["questions"]: fields = {"question_uid": question["uid"]} if question["kind"] == "single_choice": fields["option_uids"] = question["options"][1]["uid"] elif question["kind"] == "true_false": fields["answer_text"] = "true" else: fields["answer_text"] = "1024" call("POST", f"/quizzes/{slug}/attempts/{attempt_uid}/answer", fields) result = call("POST", f"/quizzes/{slug}/attempts/{attempt_uid}/finish") print(result["attempt"]["score_percent"], "percent") ``` The complete request and response reference is the [Quizzes API group](/docs/quizzes.html).