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:
LevelIntrowithlevelNumber={1}, difficulty label"Mixed", stats{ Time, Top points }, and a single "Start Quiz" CTA. - Countdown: shared
Countdowncomponent, 3-2-1-GO. - Quiz:
McqPlayfieldrenders all configured questions in random order, one at a time, against the global clock. - Results: shared
ResultsScreenwith 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
| From | Event | To |
|---|---|---|
menu | START_RUN | intro |
intro | BEGIN_COUNTDOWN | countdown |
intro | START_RUN | intro (re-arm) |
countdown | GO | playing |
playing | COMPLETE | results (last answer) |
playing | TIME_UP | results (timer expired) |
playing | PAUSE | paused |
paused | RESUME | playing |
paused | PAUSE | paused (no-op) |
results | START_RUN | intro |
| (any) | RESTART | intro |
| (any) | RESET_MENU | menu |
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.tsexports a single arrayQUIZ_QUESTION_IDS: string[]— this is the only place to edit when changing the question set.content.tsresolveQuestionSet(ids)looks each id up inMCQ_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
maxDifficultyis used as the question's own difficulty for scoring (per spec: harder questions are worth more). McqPlayfieldshuffles the resulting steps on every mount via the existingshuffleMcqStepshelper, 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 ID | Category | Difficulty | Image |
|---|---|---|---|---|
| Q1 | safety-ppe-construction-zone | Safety | Easy | — |
| Q2 | safety-ppe-grinding-sparks | Safety | Medium | — |
| Q3 | safety-ppe-framing-nail-guns | Safety | Hard | — |
| Q4 | safety-ppe-breaker-panel | Safety | Medium | — |
| Q5 | wire-3 | Safety | Easy | — |
| Q6 | precision-pm-stud-spacing | Construction | Easy | — |
| Q7 | blueprint-bp-elevation-vs-plan | Construction | Easy | tbo-ref-shelf-elevation |
| Q8 | precision-pm-wall-height | Construction | Medium | tbo-ref-stud-wall-elevation |
| Q9 | blueprint-bp-jack-vs-king | Construction | Medium | — |
| Q10 | blueprint-bp-stagger-seams | Construction | Hard | — |
| Q11 | wire-1 | Electrical | Easy | — |
| Q12 | wire-2 | Electrical | Easy | — |
| Q13 | blueprint-bp-wire-colors | Electrical | Medium | tbo-ref-wire-colors |
| Q14 | blueprint-bp-circuit-symbol | Electrical | Medium | tbo-ref-switch-circuit |
| Q15 | blueprint-bp-switch-wiring | Electrical | Medium | tbo-ref-switch-circuit |
| Q16 | pipe-slope-1 | HVAC / Plumbing | Easy | — |
| Q17 | fs-drain-pipe-size | HVAC / Plumbing | Medium | — |
| Q18 | pipe-slope-3 | HVAC / Plumbing | Medium | — |
| Q19 | blueprint-bp-vent-direction | HVAC / Plumbing | Medium | — |
| Q20 | fs-office-duct-size | HVAC / Plumbing | Medium | — |
| Q21 | clash-1 | Project Management | Easy | — |
| Q22 | clash-2 | Project Management | Easy | — |
| Q23 | clash-3 | Project Management | Easy | — |
| Q24 | clash-cc-change-order | Project Management | Hard | — |
| Q25 | blueprint-bp-revision-cloud | Project Management | Expert | — |
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.mdfirst. This game writes one row per completed run withmultiplier: 1. Thescorecolumn contains the final, all-bonuses-included integer. All breakdown fields live indata.
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 difficulty | Base points (correct) | Max speed bonus | Per-question max |
|---|---|---|---|
| Easy | 100 | 25 | 125 |
| Medium | 150 | 38 | 188 |
| Hard | 200 | 50 | 250 |
| Expert | 300 | 75 | 375 |
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 time | speedFactor | Bonus (% of base) |
|---|---|---|
| 0 ms (instant) | 1.00 | 25% |
| 1000 ms (1s) | 0.875 | 21.9% |
| 2000 ms (2s) | 0.75 | 18.75% |
| 4000 ms (4s, reference) | 0.50 | 12.5% |
| 6000 ms (6s) | 0.25 | 6.25% |
| 8000 ms (8s) | 0.00 | 0% |
| > 8000 ms | 0.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
| Question | Reaction | Correct? | Base | Speed bonus | Total |
|---|---|---|---|---|---|
| Easy | 1.0s | yes | 100 | 100 × 0.25 × 0.875 = 22 | 122 |
| Easy | 4.0s | yes | 100 | 100 × 0.25 × 0.50 = 13 | 113 |
| Easy | 10s | yes | 100 | 0 | 100 |
| Easy | any | no | 0 | 0 | 0 |
| Medium | 1.5s | yes | 150 | 150 × 0.25 × 0.8125 = 30 | 180 |
| Hard | 2.0s | yes | 200 | 200 × 0.25 × 0.75 = 38 | 238 |
| Expert | 2.0s | yes | 300 | 300 × 0.25 × 0.75 = 56 | 356 |
| Expert | 8.0s+ | yes | 300 | 0 | 300 |
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
| Constant | Value | Meaning |
|---|---|---|
RUN_TIME_LIMIT_SECONDS | 600 (10 min) | Total run clock |
END_OF_RUN_TIME_BONUS_PER_SEC | 2 | Points per remaining second when the run finishes early. Pro-rated by completion ratio inside calculateRunTotal. |
COMPLETION_BONUS | 1000 | Flat 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.
| Tier | n | Per-question max | Subtotal |
|---|---|---|---|
| Easy | 10 | 125 | 1,250 |
| Medium | 11 | 188 | 2,068 |
| Hard | 3 | 250 | 750 |
| Expert | 1 | 375 | 375 |
| Time bonus (max, completion ratio 1.0) | — | 600 × 2 | 1,200 |
| Completion bonus | — | flat | 1,000 |
| Total | 25 | 6,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_SECONDSand bumpEND_OF_RUN_TIME_BONUS_PER_SECproportionally 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.
| Grade | Min score (as % of max) | Min accuracy |
|---|---|---|
| S | 85% | 85% |
| A | 70% | 70% |
| B | 55% | 50% |
| C | 40% | 30% |
| D | 0% | 0% (fallback) |
For the shipped 25-question set on a 600s clock (max = 6,643) the absolute thresholds resolve to:
| Grade | Min score | Min accuracy |
|---|---|---|
| S | 5,647 | 85% |
| A | 4,650 | 70% |
| B | 3,654 | 50% |
| C | 2,657 | 30% |
| D | 0 | 0% |
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).
| Column | Value |
|---|---|
entityTypeId | 'contentItem' |
entityId | the platform content_item.id for States Skill Check |
scoreTypeId | 'score.game' |
score | finalScore (integer, all bonuses included) |
multiplier | 1 (per scoring rules — never anything else for this game) |
data | breakdown 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):
hasAnyCompletion()is captured (pre-update snapshot).updateBestRun(finalScore, accuracy)— merges max with any previous best.submitScore(finalScore, { …breakdown })— writes the row above.signalComplete()fires only if step 1 wasfalse(true first completion).
UX details
- Title page (
page.tsx) — single Start CTA, no level/difficulty grid. How-to-play tips inlined (per workspace rule). - Quiz —
McqPlayfieldwithmotionPreset="slideFromRight",headerTitle="States Skill Check". The playfield-leveldifficultyprop 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
PlayfieldHeaderdoes 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 asset | Import | Usage |
|---|---|---|
McqPlayfield | @/lib/mini-games/mcq/mcq-playfield | Only mini-game; renders all questions sequentially |
MCQ_POOL | @/lib/mini-games/mcq/content/pool | Source of every question |
resolvePlanImage | @/lib/images/resolve | Question image resolution (free image-MCQ support) |
LevelIntro | @/components/global/level-intro | Pre-run briefing |
Countdown | @/components/global/countdown | 3-2-1-GO |
PauseMenu | @/components/global/pause-menu | Pause overlay |
ResultsScreen | @/components/global/results-screen | End-of-run results |
LoadingScreen | @/components/global/loading-screen | Route loading states |
GameShell | @/components/layout/game-shell | Theme wrapper (abc-carolina) |
GamePageShell | @/components/layout/game-page-shell | Title page centering |
useGameActionsBase | @/lib/hooks/use-game-actions | Persistence + submitScore (writes multiplier: 1) |
useGameAudio | @/lib/hooks/use-game-audio | Phase-aware BGM (steady_beats), correct/wrong SFX |
usePersistOnResults | @/lib/hooks/use-persist-on-results | Fire-once persistence on results entry |
useGameCountdown | (inside McqPlayfield) | Wall-clock timer with subtractElapsedMs: getPauseCompensationMs |
createTransitionEngine | @/lib/stores/create-transition-engine | Guarded phase FSM |
assignGrade / getGradeStyle | @/lib/scoring | Grade 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
| Property | Status | Where |
|---|---|---|
| Wrong answer = 0 points at any difficulty/speed | ✅ pass | scoreQuestion > wrong answer always returns 0 |
| Correct answer scaling is monotonic in difficulty | ✅ pass | fairness: difficulty ordering |
| Correct answer scaling is monotonic in speed | ✅ pass | fairness: speed ordering |
| Accuracy > Speed: strict tier dominance across every difficulty boundary | ✅ pass | strict tier dominance: ANY fast correct lower-tier loses to a stale correct upper-tier |
| Max speed bonus per tier strictly smaller than tier gap | ✅ pass | speed bonus is meaningful but never overcomes difficulty |
| Adding one more correct answer never lowers the score | ✅ pass | randomized monotonicity stress test (200 random pairs) |
| Answering faster never lowers the score | ✅ pass | randomized monotonicity stress test (200 random pairs) |
| No simulated run exceeds the theoretical max | ✅ pass | randomized monotonicity stress test (100 random runs) |
| Finishers way more rewarded: Quitter (10/10 perfect) scores below every full-completion archetype, even Random Clicker | ✅ pass | Quitter now scores BELOW every full-completion archetype |
| Last-question delta > question-points alone (completion-bonus cliff is real) | ✅ pass | answering the last question is worth substantially more than just its question points |
| Completion bonus only fires when all questions answered | ✅ pass | completion bonus only fires when all questions are answered |
| Time bonus pro-rated by completion ratio | ✅ pass | time bonus is pro-rated by completion ratio |
| Grade ladder gated by both score AND accuracy | ✅ pass | S requires both score AND accuracy gates, accuracy gates the grade ladder |
multiplier: 1 rule honored (verified at call site) | ✅ pass | play/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:
- 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).
- 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.
- 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.
- 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:
| Knob | Current | Direction to push |
|---|---|---|
SPEED_BONUS_MAX_FRACTION | 0.25 | Lower (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_SEC | 2 | Lower to make speed-of-completion matter less; raise to make sprinting more important. |
COMPLETION_BONUS | 1000 | Raise 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"