← Back
Play

States Skill Check

A single-level, timed multiple-choice quiz built on top of the existing McqPlayfield. Every run draws the same configured set of questions from the shared MCQ content pools, shuffles their order, and starts one global countdown that spans the entire run. Each question's own difficulty drives how many base points it is worth, faster answers earn a speed bonus, and any unused clock at the end converts to a final time bonus.

This is intentionally the simplest game in the catalog: one level, one mini-game, one timer, no difficulty picker, and no level grid.

Game Flow

Title page → Level Intro → Countdown → Quiz (single MCQ run) → Results
  • Title page: hero card, how-to-play tips (inline, not behind accordions), single "Start Quiz" link to /states-skill-check/play.
  • Level Intro: LevelIntro with levelNumber={1}, difficulty label "Mixed", stats { Time, Top points }, and a single "Start Quiz" CTA.
  • Countdown: shared Countdown component, 3-2-1-GO.
  • Quiz: McqPlayfield renders all configured questions in random order, one at a time, against the global clock.
  • Results: shared ResultsScreen with breakdown, grade, per-question review list.

State Machine

flowchart LR
  menu -->|START_RUN| intro
  intro -->|BEGIN_COUNTDOWN| countdown
  countdown -->|GO| playing
  playing -->|COMPLETE| results
  playing -->|TIME_UP| results
  playing -->|PAUSE| paused
  paused -->|RESUME| playing
  results -->|START_RUN| intro
FromEventTo
menuSTART_RUNintro
introBEGIN_COUNTDOWNcountdown
introSTART_RUNintro (re-arm)
countdownGOplaying
playingCOMPLETEresults (last answer)
playingTIME_UPresults (timer expired)
playingPAUSEpaused
pausedRESUMEplaying
pausedPAUSEpaused (no-op)
resultsSTART_RUNintro
(any)RESTARTintro
(any)RESET_MENUmenu

Standard pause pattern: the store extends PauseState<GamePhase>, exports getPauseCompensationMs(), and freezes the wall-clock end time in runEndedAt so the displayed score and the persisted score see the exact same elapsed/remaining values.

Content

All questions come from the existing combined MCQ_POOL in lib/mini-games/mcq/content/pool.ts. The game-side content layer is intentionally minimal:

  • config.ts exports a single array QUIZ_QUESTION_IDS: string[] — this is the only place to edit when changing the question set.
  • content.ts resolveQuestionSet(ids) looks each id up in MCQ_POOL, preserves the array order, and returns { step, difficulty }[]. Missing ids are skipped with a dev-only console warning so a single bad id can't break the entire run.
  • The pool item's maxDifficulty is used as the question's own difficulty for scoring (per spec: harder questions are worth more).
  • McqPlayfield shuffles the resulting steps on every mount via the existing shuffleMcqSteps helper, so playback order is randomized run-to-run while the configured set stays deterministic.

Question set (25)

Grouped by category for readability; McqPlayfield shuffles the order every run. The per-question difficulty (used for points) is the pool item's maxDifficulty, not the position in this list.

#Pool IDCategoryDifficultyImage
Q1safety-ppe-construction-zoneSafetyEasy
Q2safety-ppe-grinding-sparksSafetyMedium
Q3safety-ppe-framing-nail-gunsSafetyHard
Q4safety-ppe-breaker-panelSafetyMedium
Q5wire-3SafetyEasy
Q6precision-pm-stud-spacingConstructionEasy
Q7blueprint-bp-elevation-vs-planConstructionEasytbo-ref-shelf-elevation
Q8precision-pm-wall-heightConstructionMediumtbo-ref-stud-wall-elevation
Q9blueprint-bp-jack-vs-kingConstructionMedium
Q10blueprint-bp-stagger-seamsConstructionHard
Q11wire-1ElectricalEasy
Q12wire-2ElectricalEasy
Q13blueprint-bp-wire-colorsElectricalMediumtbo-ref-wire-colors
Q14blueprint-bp-circuit-symbolElectricalMediumtbo-ref-switch-circuit
Q15blueprint-bp-switch-wiringElectricalMediumtbo-ref-switch-circuit
Q16pipe-slope-1HVAC / PlumbingEasy
Q17fs-drain-pipe-sizeHVAC / PlumbingMedium
Q18pipe-slope-3HVAC / PlumbingMedium
Q19blueprint-bp-vent-directionHVAC / PlumbingMedium
Q20fs-office-duct-sizeHVAC / PlumbingMedium
Q21clash-1Project ManagementEasy
Q22clash-2Project ManagementEasy
Q23clash-3Project ManagementEasy
Q24clash-cc-change-orderProject ManagementHard
Q25blueprint-bp-revision-cloudProject ManagementExpert

Composition: 10 Easy + 11 Medium + 3 Hard + 1 Expert = 25 questions.

Q1–Q4 are new single-answer MCQ reframings of existing tap-select gear-check scenarios (gear-construction-zone, gear-grinding-sparks, gear-framing-nail-guns, gear-breaker-panel in lib/mini-games/tap-select/content/pool.ts). They live in a new SAFETY_PPE_POOL section of MCQ_POOL. All other 21 questions reuse existing MCQ pool items unchanged.

Image MCQ support (free)

McqPlayfield already supports an inline question image via McqStep.planImageId and resolveImageSrc. States Skill Check passes resolvePlanImage as the resolver, so any pool item that includes a planImageId automatically renders its image above the prompt — no separate "image MCQ" mini-game is needed.

Scoring

Read game-scoring-rules.md first. This game writes one row per completed run with multiplier: 1. The score column contains the final, all-bonuses-included integer. All breakdown fields live in data.

Per-question score

For each answered question:

base       = BASE_POINTS_BY_DIFFICULTY[questionDifficulty]   // 0 if wrong
speedFactor = clamp(1 - reactionMs / 8000, 0, 1)             // 8000 = 2 * SPEED_BONUS_REFERENCE_MS
speedBonus = round(base * SPEED_BONUS_MAX_FRACTION * speedFactor)   // SPEED_BONUS_MAX_FRACTION = 0.25
total      = base + speedBonus

Wrong and skipped questions contribute 0 (and speedBonus is 0). There are no negative penalties on this game — a wrong answer just earns no points.

The 25% cap is calibrated so accuracy always beats speed across tiers. Every tier's max speed bonus is strictly smaller than the gap to the next tier (gap 50 vs Easy-max 25, gap 50 vs Medium-max 38, gap 100 vs Hard-max 50), so a stale-but-correct upper-tier answer always wins against any lower-tier answer no matter how fast.

Base points

Question difficultyBase points (correct)Max speed bonusPer-question max
Easy10025125
Medium15038188
Hard20050250
Expert30075375

Source: BASE_POINTS_BY_DIFFICULTY in config.ts.

Speed bonus

speedFactor is a linear ramp from 1 at 0 ms down to 0 at 8000 ms, clamped at both ends. The bonus caps at SPEED_BONUS_MAX_FRACTION = 0.25 (25%) of the base.

Reaction timespeedFactorBonus (% of base)
0 ms (instant)1.0025%
1000 ms (1s)0.87521.9%
2000 ms (2s)0.7518.75%
4000 ms (4s, reference)0.5012.5%
6000 ms (6s)0.256.25%
8000 ms (8s)0.000%
> 8000 ms0.00 (clamped)0%

Reaction time is measured per question, from the moment the question is first shown to when the player taps an answer. Time spent on the pause menu is subtracted via getPauseCompensationMs() so pausing mid-question can't farm the speed bonus.

Worked examples

QuestionReactionCorrect?BaseSpeed bonusTotal
Easy1.0syes100100 × 0.25 × 0.875 = 22122
Easy4.0syes100100 × 0.25 × 0.50 = 13113
Easy10syes1000100
Easyanyno000
Medium1.5syes150150 × 0.25 × 0.8125 = 30180
Hard2.0syes200200 × 0.25 × 0.75 = 38238
Expert2.0syes300300 × 0.25 × 0.75 = 56356
Expert8.0s+yes3000300

Run total

baseTotal        = sum(perQuestion.base)
speedTotal       = sum(perQuestion.speedBonus)
completionRatio  = answeredQuestions / totalQuestions      // 0..1
timeBonus        = round(max(0, remainingSeconds) * END_OF_RUN_TIME_BONUS_PER_SEC * completionRatio)
completionBonus  = answeredQuestions == totalQuestions ? COMPLETION_BONUS : 0
finalScore       = baseTotal + speedTotal + timeBonus + completionBonus
ConstantValueMeaning
RUN_TIME_LIMIT_SECONDS600 (10 min)Total run clock
END_OF_RUN_TIME_BONUS_PER_SEC2Points per remaining second when the run finishes early. Pro-rated by completion ratio inside calculateRunTotal.
COMPLETION_BONUS1000Flat bonus awarded only when every configured question has been answered.

remainingSeconds is derived from the frozen run-end timestamp:

elapsedMs        = runEndedAt - runStartedAt - getPauseCompensationMs()
remainingSeconds = max(0, RUN_TIME_LIMIT_SECONDS - elapsedMs / 1000)

Finisher reward design: skipping even a single question costs you the full COMPLETION_BONUS (1,000 pts) and ~4% of the time bonus. Skipping half the run costs ~50% of the time bonus and the 1,000-pt completion reward — partial runs simply cannot compete with finished runs on the leaderboard.

Theoretical maxes

getMaxPossibleScore(questions, RUN_TIME_LIMIT_SECONDS) returns the ceiling for a given question set. Per-question max = base + full speed bonus = base × 1.25 (with the 25% speed cap). The theoretical max assumes every question is answered, so it includes the un-prorated time bonus and the completion bonus.

TiernPer-question maxSubtotal
Easy101251,250
Medium111882,068
Hard3250750
Expert1375375
Time bonus (max, completion ratio 1.0)600 × 21,200
Completion bonusflat1,000
Total256,643

For any question set with n_E Easy + n_M Medium + n_H Hard + n_X Expert items:

max = 125*n_E + 188*n_M + 250*n_H + 375*n_X
    + END_OF_RUN_TIME_BONUS_PER_SEC * RUN_TIME_LIMIT_SECONDS
    + COMPLETION_BONUS

Pacing: 10 minutes for 25 questions leaves an average of 24 seconds per question — comfortable for thoughtful play. The speed bonus still rewards instant recall (max at < 4s reaction) but the timer itself is not a stress factor for typical play. If your team wants a tighter, more competitive feel, lower RUN_TIME_LIMIT_SECONDS and bump END_OF_RUN_TIME_BONUS_PER_SEC proportionally to keep the max-time-bonus ratio near 20-30%.

Grade thresholds

Grades scale with the run's getMaxPossibleScore() so they track changes to the question set automatically. Both minimum score and minimum accuracy must be met.

GradeMin score (as % of max)Min accuracy
S85%85%
A70%70%
B55%50%
C40%30%
D0%0% (fallback)

For the shipped 25-question set on a 600s clock (max = 6,643) the absolute thresholds resolve to:

GradeMin scoreMin accuracy
S5,64785%
A4,65070%
B3,65450%
C2,65730%
D00%

Source: getGradeThresholds(maxScore) in config.ts, evaluated with assignGrade from @/lib/scoring.

Stars

ratio = score / getMaxPossibleScore()
ratio >= 0.85  → 3 stars
ratio >= 0.60  → 2 stars
ratio >= 0.35  → 1 star
otherwise       → 0 stars

For the shipped 25-question set on a 600s clock: 3⭐ ≥ 5,647, 2⭐ ≥ 3,986, 1⭐ ≥ 2,325.

Accuracy

accuracy = round(correctAnswers / totalAnswered * 100)

Computed across answers actually given (not against questions.length). If the timer expires before the player gets to a question, that question is simply not in the answers array and does not pull accuracy down. It does pull finalScore down because its base + speed contribution is 0.

What is written to the score table

Every completed run writes exactly one row via submitScore(finalScore, data) from the shared useGameActionsBase hook (use-game-actions.ts).

ColumnValue
entityTypeId'contentItem'
entityIdthe platform content_item.id for States Skill Check
scoreTypeId'score.game'
scorefinalScore (integer, all bonuses included)
multiplier1 (per scoring rules — never anything else for this game)
databreakdown JSON (see below)

data payload (illustrative — perQuestion[] shows 4 of 25 entries for brevity):

{
  "gameSlug": "states-skill-check",
  "totalQuestions": 25,
  "answeredQuestions": 25,
  "correctAnswers": 22,
  "accuracy": 88,
  "baseTotal": 3050,
  "speedTotal": 573,
  "timeBonus": 1104,
  "completionBonus": 1000,
  "completionRatio": 1,
  "elapsedMs": 48000,
  "remainingSeconds": 552,
  "fastestCorrectMs": 1420,
  "perQuestion": [
    { "stepId": "wire-1", "difficulty": "Easy", "isCorrect": true, "reactionMs": 1420, "base": 100, "speed": 21, "total": 121 },
    { "stepId": "blueprint-bp-wire-colors", "difficulty": "Medium", "isCorrect": true, "reactionMs": 2100, "base": 150, "speed": 28, "total": 178 },
    { "stepId": "blueprint-bp-stagger-seams", "difficulty": "Hard", "isCorrect": false, "reactionMs": 3300, "base": 0, "speed": 0, "total": 0 },
    { "stepId": "blueprint-bp-revision-cloud", "difficulty": "Expert", "isCorrect": true, "reactionMs": 2800, "base": 300, "speed": 49, "total": 349 }
  ]
}

The score value (baseTotal + speedTotal + timeBonus + completionBonus) is what feeds into platform leaderboards via MAX(score * multiplier). In the example above: 3050 + 573 + 1104 + 1000 = 5727 (an S-grade finish).

If answeredQuestions < totalQuestions, completionBonus is 0 and timeBonus is scaled by completionRatio — the same un-finished run would lose both the flat 1,000-pt bonus and ~50%+ of its time bonus, dropping comfortably to D/C territory.

Persistence

Single-best-run pattern via useGameActionsBase<SaveGameData>("states-skill-check-state"). Save data shape:

interface SaveGameData {
  bestRun?: {
    bestScore: number;
    bestAccuracy: number;
    attempts: number;
    completedAt?: string;
  };
}

There is no level progression and no per-difficulty grid. The hook's custom hasAnyCompletion() checks state.bestRun (not levelProgress) so signalComplete() fires only on the player's first-ever finish.

On entering results (via usePersistOnResults):

  1. hasAnyCompletion() is captured (pre-update snapshot).
  2. updateBestRun(finalScore, accuracy) — merges max with any previous best.
  3. submitScore(finalScore, { …breakdown }) — writes the row above.
  4. signalComplete() fires only if step 1 was false (true first completion).

UX details

  • Title page (page.tsx) — single Start CTA, no level/difficulty grid. How-to-play tips inlined (per workspace rule).
  • QuizMcqPlayfield with motionPreset="slideFromRight", headerTitle="States Skill Check". The playfield-level difficulty prop is hardcoded to "Medium" because per-question difficulty drives our scoring and the playfield's internal weighted score is ignored.
  • Floating toolbar — small mute + pause buttons in the top-right corner of the play screen (the playfield's PlayfieldHeader does not have a built-in pause).
  • Pause overlay — shared <PauseMenu scrimClassName="bg-background"> (opaque) so the in-progress question is not visible through the overlay.
  • Results — shared <ResultsScreen> showing animated final score, grade, four stats (Accuracy, Correct/Total, Fastest, Time Left), three breakdown rows (Base points, Speed bonus, Time bonus), and a per-question review list as children (✓ / ✗ with difficulty label, reaction time, points awarded).
  • Theme — reuses abc-carolina. No new CSS file.

Shared platform usage

Shared assetImportUsage
McqPlayfield@/lib/mini-games/mcq/mcq-playfieldOnly mini-game; renders all questions sequentially
MCQ_POOL@/lib/mini-games/mcq/content/poolSource of every question
resolvePlanImage@/lib/images/resolveQuestion image resolution (free image-MCQ support)
LevelIntro@/components/global/level-introPre-run briefing
Countdown@/components/global/countdown3-2-1-GO
PauseMenu@/components/global/pause-menuPause overlay
ResultsScreen@/components/global/results-screenEnd-of-run results
LoadingScreen@/components/global/loading-screenRoute loading states
GameShell@/components/layout/game-shellTheme wrapper (abc-carolina)
GamePageShell@/components/layout/game-page-shellTitle page centering
useGameActionsBase@/lib/hooks/use-game-actionsPersistence + submitScore (writes multiplier: 1)
useGameAudio@/lib/hooks/use-game-audioPhase-aware BGM (steady_beats), correct/wrong SFX
usePersistOnResults@/lib/hooks/use-persist-on-resultsFire-once persistence on results entry
useGameCountdown(inside McqPlayfield)Wall-clock timer with subtractElapsedMs: getPauseCompensationMs
createTransitionEngine@/lib/stores/create-transition-engineGuarded phase FSM
assignGrade / getGradeStyle@/lib/scoringGrade lookup in results

Shared mini-game change

McqPlayfield gained one optional, non-breaking prop:

onAnswer?: (info: {
  stepId: string;
  correctIndex: number;
  selectedIndex: number;
  isCorrect: boolean;
  reactionMs: number;
}) => void;

Fires once per question when the player selects an answer. reactionMs is measured from when the question is first shown (or re-shown after resume) and subtracts pause time via the getPauseCompensationMs prop. Existing callers (Site Audit ClashCorrectionCheckpoint, Site Sprint Relay, Trades Build Off MCQ phases, etc.) ignore the prop and behave identically.

File structure

games/states-skill-check/
├── store.ts                       # Zustand store + transition engine
├── config.ts                      # RUN_TIME_LIMIT_SECONDS, BASE_POINTS_BY_DIFFICULTY,
│                                  # SPEED_BONUS_*, QUIZ_QUESTION_IDS,
│                                  # getMaxPossibleScore, getGradeThresholds, calculateStars
├── types.ts                       # GamePhase, GameEvent, QuizQuestion,
│                                  # QuestionAnswer, SaveGameData, StoredBestRun
├── scoring.ts                     # scoreQuestion, calculateRunTotal,
│                                  # calculateAccuracy, fastestCorrectMs
├── content.ts                     # resolveQuestionSet(ids) → MCQ_POOL lookup
├── index.ts                       # Barrel export
├── components/
│   ├── quiz-phase.tsx             # Thin wrapper around McqPlayfield
│   └── run-results.tsx            # Wraps ResultsScreen + per-question review list
└── hooks/
    ├── use-game-actions.ts        # Persistence wrapper (single-best-run)
    └── use-game-audio.ts          # BGM/SFX management

app/(games)/states-skill-check/
├── layout.tsx                     # GameShell theme="abc-carolina"
├── loading.tsx                    # Route loading state
├── page.tsx                       # Title page (server component)
└── play/
    ├── loading.tsx                # Play loading state
    └── page.tsx                   # Client orchestrator (state machine, overlays, persist)

Stress-test findings

The scoring model is exercised by tests/games/states-skill-check/scoring.test.ts — 46 tests, 681 assertions covering per-question math, run-total math (including completion-bonus and pro-rated time-bonus), archetype rankings, randomized monotonicity (200+200+100 random pairs), and grade thresholds. All currently pass.

There is also a non-test scoreboard runner at tests/games/states-skill-check/_scoreboard.ts you can invoke with bun run tests/games/states-skill-check/_scoreboard.ts to see simulated player scores against the live config.

Validated properties

PropertyStatusWhere
Wrong answer = 0 points at any difficulty/speed✅ passscoreQuestion > wrong answer always returns 0
Correct answer scaling is monotonic in difficulty✅ passfairness: difficulty ordering
Correct answer scaling is monotonic in speed✅ passfairness: speed ordering
Accuracy > Speed: strict tier dominance across every difficulty boundary✅ passstrict tier dominance: ANY fast correct lower-tier loses to a stale correct upper-tier
Max speed bonus per tier strictly smaller than tier gap✅ passspeed bonus is meaningful but never overcomes difficulty
Adding one more correct answer never lowers the score✅ passrandomized monotonicity stress test (200 random pairs)
Answering faster never lowers the score✅ passrandomized monotonicity stress test (200 random pairs)
No simulated run exceeds the theoretical max✅ passrandomized monotonicity stress test (100 random runs)
Finishers way more rewarded: Quitter (10/10 perfect) scores below every full-completion archetype, even Random Clicker✅ passQuitter now scores BELOW every full-completion archetype
Last-question delta > question-points alone (completion-bonus cliff is real)✅ passanswering the last question is worth substantially more than just its question points
Completion bonus only fires when all questions answered✅ passcompletion bonus only fires when all questions are answered
Time bonus pro-rated by completion ratio✅ passtime bonus is pro-rated by completion ratio
Grade ladder gated by both score AND accuracy✅ passS requires both score AND accuracy gates, accuracy gates the grade ladder
multiplier: 1 rule honored (verified at call site)✅ passplay/page.tsx only passes score to submitScore; useGameActionsBase writes 1

Simulated scoreboard (10 player archetypes, 25 questions, 600s clock)

Rank Player                           Score  Grade Acc% Correct Base  Speed TimeB Done  ElapsedS
-------------------------------------------------------------------------------------------------
 1   Cheater (100%, 200ms)            6604   S     100  25/25   3550   864  1190  1000     5
 2   Expert (95%, 2s)                 5727   S      88  22/25   3050   573  1104  1000    48
 3   Strong (85%, 4s)                 5496   A      88  22/25   3100   393  1003  1000    98
 4   Slow + Accurate (90%, 15s)       4911   A      96  24/25   3450     0   461  1000   370
 5   Average (70%, 8s)                4372   B      68  17/25   2550    33   789  1000   206
 6   Speedy + Careless (50%, 1s)      4158   C      44  11/25   1650   360  1148  1000    26
 7   Below Average (50%, 10s)         3694   B      60  15/25   2000     5   689  1000   256
 8   Random Clicker (25%, 100ms)      3318   D      28   7/25    900   223  1195  1000     3
 9   Struggling (30%, 12s)            2604   D      28   7/25   1000     0   604  1000   298
10   Quitter (100% × 10 q only)       2111   D     100  10/10   1400   249   462     0    23

Key observations:

  1. Quitter is dead last. Even with 100% accuracy on what they answered, skipping 15 questions cost them the 1,000-pt completion bonus and 60% of the time bonus. They fall to D grade and last place, 1,200 pts behind the next-worst player (Struggling, who answered all 25 at 28% accuracy).
  2. Speedy-careless can't out-grade an honest mid-tier player. Speedy + Careless has a higher raw score (4,158) than Below Average (3,694), but its 44% accuracy fails the B grade's 50% accuracy gate, capping it at C. Below Average's 60% accuracy gets it B.
  3. Top of the leaderboard is dominated by accuracy + completion, not raw speed. The Cheater wins because they did everything right; everyone else's order tracks how many they got right.
  4. No tier inversions. A stale Hard answer (200 pts) still beats a fast Medium answer (188 pts max).

Knobs your team can adjust later

All three live as named constants in config.ts, so retuning is a one-line change followed by re-running the stress test:

KnobCurrentDirection to push
SPEED_BONUS_MAX_FRACTION0.25Lower (e.g. 0.15) to make accuracy even more dominant; raise toward 0.33 to give speed more weight (still no tier inversions until 0.33+).
END_OF_RUN_TIME_BONUS_PER_SEC2Lower to make speed-of-completion matter less; raise to make sprinting more important.
COMPLETION_BONUS1000Raise to widen the finisher / non-finisher gap further; lower to soften it.

Validation checklist (for QA review)

Score integrity:
  [ ] submitScore always writes multiplier: 1 (default of useGameActionsBase)
  [ ] score column contains the final integer (baseTotal + speedTotal + timeBonus + completionBonus)
  [ ] score_type_id = 'score.game', entity_type_id = 'contentItem'
  [ ] data JSON includes perQuestion[] with per-question {base, speed, total}
  [ ] data JSON includes top-level baseTotal, speedTotal, timeBonus, completionBonus, completionRatio, accuracy

Per-question math:
  [ ] Wrong answer at any reaction time = 0 points
  [ ] Correct, instant (<= 50ms) Easy answer = 100 base + 25 speed = 125
  [ ] Correct, 4s Easy answer = 100 base + 13 speed = 113
  [ ] Correct, 8s+ Easy answer = 100 base + 0 speed = 100
  [ ] Higher difficulty caps: Medium max 188, Hard max 250, Expert max 375
  [ ] No tier inversions: a stale correct Hard (200) always beats any fast correct Medium (max 188)

Run-level math:
  [ ] Finishing all 25 questions adds COMPLETION_BONUS (1,000 pts)
  [ ] Time bonus is pro-rated by answered/total — a 10/25 quitter earns 40% of their time bonus
  [ ] Quitter (10/10 perfect) scores LESS than any full-completion player (verified by stress test)
  [ ] Run completed with 30s remaining + all answered → timeBonus = 60 (30 * 2)
  [ ] Run completed by timer expiry but all answered → timeBonus = 0, completionBonus = 1000
  [ ] Grade thresholds scale with question set (S still requires 85% of max, 85% accuracy)

Pause behavior:
  [ ] Pausing during a question does NOT inflate the speed bonus on resume
  [ ] Pausing freezes the global timer (remainingSeconds at end matches pre-pause clock)
  [ ] PauseMenu uses opaque scrim so the question is hidden

Flow:
  [ ] First completion fires signalComplete (TNW_COMPLETE)
  [ ] Subsequent runs do NOT re-fire signalComplete but DO write a new score row
  [ ] Best-run save (bestScore / bestAccuracy / attempts) updates correctly
  [ ] Guest mode persists to localStorage under key "states-skill-check-state:v1"