Game-facing question semantics contract (slice 1)
Date: 2026-08-14
Ticket: trashcat#871
Audience: external game authors consuming @trilogy-group/fluency-platform
Purpose
Document which fields on a presented question a game may build content-driven mechanics on, what absence means, and how to read optional numeric values safely. The bridge forwards the full parsed NullableQuestion deliberately; this page names the slice-1 promise so that passthrough is a contract, not an accident.
Slice 1 promise
| Field | Meaning | Absence |
|---|---|---|
choices[].value |
Optional numeric semantics of the choice (game-facing). | Key omitted or non-finite → treat as “no numeric value”. |
choices[].label |
The only display string for a choice. | Always present on shipped math content; render this for UI copy. |
question.responseMode |
Answer-input format: choice | integer | decimal | percent | fraction. Not a per-choice value type. |
Always present on presented questions. |
Fraction content may put the decimal quotient in value and the fraction string in label (see the second payload below).
Degradation rule
- Always render
choices[].labelfor display. - For any numeric mechanic (split count, projectile count, lane math, etc.), read through
choiceNumericValue. When it returnsnull, take the no-value branch — do not coerce, do not use sentinels, do not fall through to0.
import { choiceNumericValue } from '@trilogy-group/fluency-platform/components';
const n = choiceNumericValue(choice);
if (n === null) {
// no numeric semantics on this choice — skip or use a non-numeric path
} else {
// use n
}
Explicitly not promised (may change without notice)
A game that reads these fields accepts breakage when the platform reshapes them:
- Everything under
fact.*(cardType,text,factorA/factorB,operator, fraction operands,answer, relationships) suppliedDistractors- Timing and attempt bookkeeping (
timeToAnswer, intervention history, premature flags, session bookkeeping fields beyond what the message envelope already documents)
Named later slices will cover additional fields; each needs its own agreement.
Captured payloads (integration, 2026-07-29, Snake Arena)
Multiplication (PresentQuestionInGame)
Skill CCSS.MATH.CONTENT.4.NBT.B.5+1 (Times 11–12):
{
"timestamp": 1785346693361,
"messageType": "PresentQuestionInGame",
"question": {
"id": "2d944cad-5a08-4929-bfd2-d155cafbcdd3",
"stageId": "assessment",
"stageType": "assessment",
"factId": "11x12",
"factSetId": "11",
"presentedFactId": "11x12",
"isTransferProbe": false,
"text": "11 x 12 = ?",
"choices": [
{ "id": "c1", "value": 132, "label": "132", "correct": true },
{ "id": "c4", "value": 142, "label": "142", "correct": false },
{ "id": "c3", "value": 131, "label": "131", "correct": false },
{ "id": "c2", "value": 133, "label": "133", "correct": false }
],
"responseMode": "choice",
"sessionId": "<redacted>",
"createdAt": "2026-07-29T17:38:13.079Z",
"interventionHistory": [],
"fact": {
"id": "11x12",
"cardType": "MathBinary",
"text": "11 x 12 = ?",
"answer": { "value": 132, "label": "132" },
"factorA": 11,
"factorB": 12,
"operator": "x",
"factSetId": "11",
"reviewResponse": { "mode": "integer", "isApproximate": false, "expectedValue": 132 },
"result": 132,
"relationships": []
},
"sourceFactId": "11x12",
"premature": false
},
"skillId": "CCSS.MATH.CONTENT.4.NBT.B.5+1",
"sessionId": "<redacted>",
"algorithmId": "practice",
"baseTimeToAnswerSec": 0,
"bonusTimeToAnswerSec": 1.4396371882086167
}
Fraction (PresentQuestionInGame)
Skill CCSS.MATH.CONTENT.5.NF.A.1 (Fraction Operations). Note value is the decimal quotient while label carries the fraction string:
{
"timestamp": 1785346952881,
"messageType": "PresentQuestionInGame",
"question": {
"id": "c635b0da-ac35-42e6-9309-df5ef0790272",
"stageId": "mastered",
"stageType": "mastered",
"factId": "frac:3/5-1/5",
"factSetId": "frac-sub-like",
"presentedFactId": "frac:3/5-1/5",
"isTransferProbe": false,
"text": "3/5 - 1/5 = ?",
"choices": [
{ "id": "c1", "value": 0.4, "label": "2/5", "correct": true },
{ "id": "c3", "value": 0.6, "label": "3/5", "correct": false },
{ "id": "c4", "value": 0.375, "label": "3/8", "correct": false },
{ "id": "c2", "value": 0.2, "label": "1/5", "correct": false }
],
"responseMode": "choice",
"sessionId": "<redacted>",
"createdAt": "2026-07-29T17:42:32.623Z",
"interventionHistory": [],
"fact": {
"id": "frac:3/5-1/5",
"cardType": "MathRational",
"text": "3/5 - 1/5 = ?",
"answer": { "value": 0.4, "label": "2/5" },
"suppliedDistractors": [
{ "value": 0.2, "label": "1/5" },
{ "value": 0.6, "label": "3/5" },
{ "value": 0.375, "label": "3/8" }
],
"fractionOperation": {
"operandA": { "numerator": 3, "denominator": 5 },
"operandB": { "numerator": 1, "denominator": 5 },
"operator": "-",
"result": { "numerator": 2, "denominator": 5 }
},
"factSetId": "frac-sub-like",
"reviewResponse": {
"mode": "fraction",
"isApproximate": false,
"expectedFraction": { "numerator": 2, "denominator": 5 }
},
"relationships": []
},
"sourceFactId": "frac:3/5-1/5"
},
"skillId": "CCSS.MATH.CONTENT.5.NF.A.1",
"sessionId": "<redacted>",
"algorithmId": "practice",
"baseTimeToAnswerSec": 0,
"bonusTimeToAnswerSec": 1.811156462585034
}
Bridge delivery
Games receive the question on PresentQuestionInGame and (when the game accepts presentation) again on QuestionStarted. Both message types type question as a passthrough NullableQuestion — see TSDoc on those fields in @trilogy-group/fluency-platform/bridge.
ITD: We model Choice.value as optional (not nullable, never a sentinel) so games branch on two states
Optional key omission is the only “no numeric value” representation. z.number().nullable() was rejected because it creates a third state (null on the wire and key omission). A required field with a sentinel (0, NaN, -1) was rejected because sentinels collide with legitimate answers and pass every type check. Only choiceSchema.value is optionalized; cardAnswerSchema.value under fact.* stays out of slice 1.
ITD: We keep the bridge question field as a wide passthrough and pin the promise with docs + tests
Replacing z.custom<NullableQuestion>() with a composed Zod object was rejected: a strict schema would fail on additive backend fields and couple backend releases to frontend releases. The ticket asks for an intentional passthrough, not a narrowed one. TSDoc, this page, and schema/send-site guard tests make the promise self-falsifying without narrowing the type.
