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

  1. Always render choices[].label for display.
  2. For any numeric mechanic (split count, projectile count, lane math, etc.), read through choiceNumericValue. When it returns null, take the no-value branch — do not coerce, do not use sentinels, do not fall through to 0.
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.