Quiz
A whole quiz, game show or spoken test through one endpoint
Experimental
The quiz engine is currently an experimental feature. Its request and response shape may change.
One of your characters hosts a quiz. You send a character id and a question set id once, then forward whatever the player said for the rest of the run. Question order, retries, hints, scoring and when to stop are authored on the question set and decided on the server, so none of it lives in your game.
POST https://api.neocortex.link/v3/quiz
GET https://api.neocortex.link/v3/quiz/sessionPlan requirement
The quiz engine requires a Team plan. See Question Sets for authoring the set the run plays.
The whole integration
Begin a run
Two ids and nothing else. You get back the host's first words and, usually, the first question.
curl -X POST https://api.neocortex.link/v3/quiz \
-H "x-api-key: YOUR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"characterId": "YOUR_CHARACTER_ID",
"questionSetId": "YOUR_QUESTION_SET_ID"
}'Forward what the player said
Every turn after the first is the session id plus the player's words. Send the transcript exactly as your speech recognition produced it. The host works out whether it was an answer, a request for a clue, a yes, a no, or something it could not make out.
{
"sessionId": "RETURNED_SESSION_ID",
"say": "ummm I think it's a triangle"
}Put the scene on screen and reopen the microphone
Read question to build the scene, expecting to decide whether to listen, and done to know the run is over. Repeat until done is true.
Reading a turn
{
"sessionId": "cmu9w20hi00527k9kpcoirzuw",
"lines": [
{ "text": "Close! Think about the corners.", "emotion": "CURIOUS", "role": "reaction" },
{ "text": "What shape has three sides?", "emotion": "CURIOUS", "role": "question" }
],
"expecting": "answer",
"intent": "answer",
"result": [{ "verdict": "INCORRECT", "score": 0, "participantId": "player-1" }],
"leaderboard": [
{ "participantId": "player-1", "displayName": "Player", "totalScore": 0, "rank": 1 }
],
"done": false
}| Field | Description |
|---|---|
sessionId | Always sent. Identifies which run this payload belongs to, so a logged or queued response cannot be mistaken for another player's. |
lines[] | What the host says, in order, each with its own emotion and role. |
expecting | What to collect next: answer, confirmation or nothing. |
intent | How the player was read. Omitted on a turn where nobody had spoken. |
question | The question now on screen. Sent on the turn it changes and not repeated after. |
upcoming | The question after this one, so a scene can be prepared before it is asked. |
progress | asked is which question is on the table, total is how many the run serves. Travels with question. |
result[] | How the last answer was judged. Omitted on a turn that judged nothing. |
leaderboard[] | The standings. Sent only when a score could have moved. |
pending[] | Who the round is still waiting on. Omitted for a single player. |
done | true once the host has signed off. |
Anything absent means there is nothing to report. A field that is always present and usually null teaches a reader nothing, so only what a turn actually carries is sent.
Line roles
role says what each line is doing, so you can time the scene to it rather than to the turn.
| Role | Meaning |
|---|---|
greeting | Welcoming the players. |
reaction | Responding to what the player said. |
question | The words that actually put a question to them. |
closing | Signing off. |
Show the next thing on screen as the question line begins, not while the host is still reacting to the last answer.
spokenText appears on a line only when it respells that line for speech, for example "Alex +100" spoken as "Alex plus one hundred points". When it is absent, speak text.
Intents
| Intent | What the player was doing |
|---|---|
answer | Attempting the question. A wrong answer is still an answer. |
hint | Asking for a clue, or saying they do not know. |
repeat | Asking for the question again. |
affirm / decline | Accepting or turning down another go, when one was offered. |
stop | Asking to finish. |
unclear | Nothing usable came through. Costs the player no attempt. |
Only answer can score. A request for a clue or a turn the host could not make out never spends an attempt.
The question on screen
{
"question": {
"questionId": "q_mfx2k1_a7c2",
"keyword": "SHOW_RED_APPLE",
"type": "multiple_choice",
"questionText": "Which one is the apple?",
"options": [
{ "id": "opt_a", "text": "Apple" },
{ "id": "opt_b", "text": "Pear" }
],
"hasHint": true
},
"upcoming": { "questionId": "q_mfx2k4_b913", "keyword": "SHOW_GREEN_PEAR" },
"progress": { "asked": 1, "total": 5 }
}keyword is the author's own name for the scene a question needs, in the same shape as an action keyword. Question ids are generated and mean nothing to a game; key your scene on the keyword instead, and use the one on upcoming to preload the next.
hasHint is only present when the run will actually give a clue. A set with hints turned off never advertises one, even on a question whose author wrote hint text.
The answer key never reaches the player
Expected answers, rubrics, hint text and the rest of the queue stay on the server. A contestant's device would otherwise receive the answer along with the question. The correct answer appears only in the round history, and only once the run has finished.
Answering
| You send | What it means |
|---|---|
say | What the player said or typed, up to 512 characters. |
optionId | They tapped an option. Send this instead of, or alongside, say. |
participantId | Who spoke. Required once there is more than one player. |
say: "" | An explicit pass. The round is recorded unanswered and the run moves on, with no model call. |
An empty transcription is not a pass
Never route silence or a failed transcription through as say: "". Speech the host could not make out is an utterance, and the engine has a ladder for it: it asks again, then volunteers the clue, then moves the player along. Sending an empty string instead tells the engine the player chose to skip.
An authored welcome is its own beat
When the set has opening words written on it, the first call returns only the greeting: question is absent and expecting is nothing. The turn after that asks question one. This is so a game can greet the player, dress the scene, and only then put a question on screen.
Leave the opening blank and the run opens on the first question instead, in the same breath as the welcome.
The same applies to the closing words. With none written, the run ends on the host's last reaction.
Several players
{
"characterId": "YOUR_CHARACTER_ID",
"questionSetId": "YOUR_QUESTION_SET_ID",
"participants": [
{ "id": "alex", "displayName": "Alex", "type": "human" },
{ "id": "maya", "displayName": "Maya", "type": "human" }
]
}Up to ten participants, and their ids must be unique. Send participantId with every answer so the right person is credited. The round waits until everyone has answered before anything is judged, and pending tells you who is left.
Omit participants entirely for a single player.
AI contestants
A participant of type: "ai" is refused with ai_contestants_unsupported. Nothing generates their answers yet, so accepting one would leave the round waiting on a player that never speaks.
Reading a run back
If a client loses its place, restarts, or you want a scoreboard, read the whole run.
curl "https://api.neocortex.link/v3/quiz/session?sessionId=SESSION_ID" \
-H "x-api-key: YOUR_API_KEY"{
"state": {
"phase": "collecting",
"roundNumber": 2,
"totalQuestions": 5,
"questionsRemaining": 3,
"participants": [{ "id": "player-1", "displayName": "Player", "type": "human" }],
"activeQuestion": { "questionId": "q_mfx2k4_b913", "type": "free_text", "questionText": "And this one?", "hasHint": false },
"answeredParticipantIds": [],
"pendingParticipantIds": ["player-1"],
"leaderboard": [{ "participantId": "player-1", "displayName": "Player", "totalScore": 100, "rank": 1 }],
"startedAt": "2026-09-20T09:14:02.881Z",
"finishedAt": null,
"nextAction": "evaluate",
"expecting": "answer"
},
"leaderboard": [],
"roundHistory": [],
"questionSet": { "name": "Shape School", "evaluationProtocol": "educational" },
"startedAt": "2026-09-20T09:14:02.881Z",
"finishedAt": null
}state.expecting is the same value a turn carries, so a client whose turn response was lost can reopen the microphone instead of guessing. roundHistory carries every round played, with the host's lines and the verdicts, and gains correctAnswer on each round once the run is finished.
Scores never come from the host
Every point is derived from the question's own scoring table. The host reads the player and reports a verdict for open answers; the engine works out the number. A host that proposes a retry the set forbids, or a verdict on a turn that was not an answer, changes nothing.
result[].score is what actually reached the standings. On a replay the set does not count it is 0, while the verdict still says whether they were right, so you can celebrate a correct answer without the leaderboard disagreeing with you.
Multiple choice and true or false questions are graded in code. When the words do not name an option outright, the host is asked which option they pointed at and the engine compares that pick to the answer key, so a player answering in their own language is not marked wrong by a string comparison.
Errors
| Status | Code | Meaning |
|---|---|---|
400 | invalid_json | The request body was not JSON. |
400 | invalid_question_set | The set has a question or a play policy that cannot be played. |
401 | Missing or invalid API key. | |
403 | The quiz engine requires a Team plan. | |
404 | The session, character or question set does not exist on your team. | |
422 | missing_ids | No sessionId, and not both of characterId and questionSetId. |
422 | unknown_participant | The participantId is not in this run. |
422 | unknown_option | The optionId is not one of this question's options. |
422 | nothing_said | A question is open but the call carried neither say nor optionId. |
422 | ai_contestants_unsupported | A participant of type ai was supplied. |
409 | operation_in_progress | Another turn is already running for this session. Retry after the Retry-After header. |
409 | quiz_finished | The run is over. The body carries the closing turn again under turn. |
502 | invalid_model_output | The host's reply could not be read. Nothing was charged. |
502 | model_output_truncated | The host's reply was cut short. Nothing was charged. |
One turn at a time
A session runs one turn at a time. A second request while a turn is in flight gets 409 with a Retry-After header rather than a second, conflicting turn, so a double tap cannot produce two answers to one question.
Request reference
{
"sessionId": "", // Continue a run. Omit on the first call
"characterId": "", // Required on the first call, the host of the quiz
"questionSetId": "", // Required on the first call, the set to play
"playerId": "", // Optional, your own id for this player
"participants": [], // Optional, up to 10, unique ids. Omit for one player
"participantId": "", // Who spoke, once there is more than one player
"say": "", // What the player said. "" is an explicit pass
"optionId": "" // The option they tapped
}