Feature: AI Vocab Hangman Game
Metadata
- Issue ID: FEAT-89
- Status: Done
- Owner: Kzu0-afk
- Related PRs:
89-hangman-game->dev(StudyBoostIO/StudyboostV2#89)
Overview
FEAT-89 introduces an AI-powered Vocab Hangman mini-game at /ai/hangman-game. The AI extracts key vocabulary terms from the player's selected document or course scope and writes a clear definition for each. The player sees the definition first and must guess the term one letter at a time before running out of lives. Wrong guesses are visualised through a study-themed lives indicator (default: a crumbling book tower). Spaces, hyphens, and digits are pre-revealed Scribble.io style.
The feature extends the FEAT-60 / FEAT-61 AI tool foundation and follows the conventions of FEAT-78 (typing game), FEAT-80 (crossword game), and FEAT-90 (letter tile game) — no new database tables, no new controllers for generation, shared frontend primitives, server-authoritative gameplay.
Core product goal
- Reinforce definition-to-term recall — the inverse of traditional flashcards — using vocabulary drawn from the player's own study material.
- Keep the round flow tight (6 lives per term, instant feedback) so a full session takes 3-5 minutes.
- Reuse the centralised AI tool architecture rather than introducing one-off controllers, persistence shapes, or frontend primitives.
Canonical feature doc: docs/features/FEAT-89-Hangman-Game.md.
Difficulty levels
| Level | Term profile | Multiplier |
|---|---|---|
easy | Short, common terms (<= 8 letters); clear definitions | x1.0 |
medium | Moderate-length terms (8-14 letters); standard academic definitions | x1.2 |
hard | Long or technical terms (> 14 letters / compound); dense definitions | x1.5 |
mixed | Approx. 30% easy / 50% medium / 20% hard; per-term multiplier | per-term |
Adaptive term count
| Source size (approx. char count) | Term count |
|---|---|
| Small (< 2 000 chars) | 5 |
| Medium (2 000 - 8 000 chars) | 8 |
| Large (> 8 000 chars) | 11 |
Optional termCount DTO override clamped to [3, 15].
Frontend Behavior
Route: /ai/hangman-game — frontend/app/(features)/ai/hangman-game/page.tsx.
Page composed with AiPageShell + DocumentPickerTrigger + useAiTool<HangmanGameSession>("hangman-game"). A Setup -> Playing -> Results state machine lives in one "use client" file (matches sibling-game convention — no game-specific subdirectory). The AI hub GAME_TOOLS array gains a Vocab Hangman entry (BookKey icon, rose accent, id: "hangman").
Phase: Setup
- Difficulty picker (
easy/medium/hard/mixed) with a one-line description per level. - Optional term-count override:
Auto(default, adaptive) /5/8/10/15. DocumentPickerwithsubmitLabel="Generate Game".- Recent-sessions panel via
AiHistoryPanel, listing prior sessions with best score per session.
Phase: Playing
Per-round layout (top -> bottom):
- Definition panel — prominent display of the current term's definition.
- Blank slots row — underscore slot per letter; spaces, hyphens, and digits render as fixed non-interactive glyphs between letter groups.
- Lives indicator — crumbling book tower (6 books); one book slides off per wrong guess; A11y label reports "N lives remaining".
- Letter grid (A-Z) — correct letters turn green, wrong letters turn muted red/grey and are disabled, already-guessed letters are non-clickable.
- Hint button — shows remaining-token badge; disabled at 0 tokens or after the round ends.
- Progress indicator —
Term N of M. - Round result overlay — "You got it! +N pts" on win; "The term was: TERM" on loss; auto-advances after 2 s.
Phase: Results
- Total score plus best-score comparison from
GET /recent. - Per-term breakdown: term (revealed), definition, win/loss, hint used, wrong guesses, round score.
- Actions:
Play Again(new session) /Back to AI Tools.
Validation and error handling
- Hint button disabled when
hintTokensRemaining === 0or the round has ended. - Already-guessed and resolved-wrong letters are non-clickable in the grid.
- Generation errors (unprocessed document, foreign document, insufficient content, plan failure) surface as safe inline messages on the Setup screen.
Backend Behavior
Tool: HangmanGameTool (toolId: "hangman-game")
Location: backend/src/ai/tools/games/hangman-game.tool.ts. Extends BaseAiTool; injects PrismaService, GeminiGenerationProvider, AiScopeResolverService, SubscriptionsService. Constants: MAX_SOURCE_CHARACTERS = 14_000, MAX_CHUNKS = 14, MAX_TERMS = 15, MIN_TERM_LENGTH = 3, LIVES_PER_TERM = 6, HINT_TOKENS_PER_SESSION = 3. Registered in AiModule providers, the AI_TOOL_REGISTRY factory tuple + inject list, and exports.
DTO
backend/src/ai/dto/generate-hangman-game.dto.ts — GenerateHangmanGameDto with documentId?, scope?, difficulty? (default mixed), termCount? (clamped [3, 15]). class-validator decorators (@IsOptional, @IsEnum, @IsInt, @Min(3), @Max(15)). DTO validation rejects empty scope, unsupported difficulty, and termCount outside [3, 15].
Generation flow (POST /ai/tools/hangman-game)
- Resolve source text via
documentId(single doc) orscope(multi-doc/course) usingAiScopeResolverService. - AI call (Gemini,
responseFormat: "json") extracts{ term, definition, difficulty }[]. - Post-processing rejects terms shorter than 3 letters (after stripping non-letter glyphs), definitions containing the term verbatim (alphanumeric-only substring check), and duplicate terms. For
mixed, resamples once if the per-term difficulty distribution is more than +/-20% off the 30/50/20 target. - Build per-round storage: compute
fixedGlyphs(positions of spaces / hyphens / digits),slotCount, and initial state (guessedLetters = [],wrongGuesses = 0,hintUsed = false,roundComplete = false). - If fewer than 3 valid terms remain, throw
InternalServerErrorException("insufficient content for hangman"). - Persist the full
StoredHangmanSessiontoai_game_sessions.questionsJSONB. Thetermfield is never returned in any public payload during active play.
Endpoints
All endpoints carry class-level CookieAuthGuard + PlanAccessGuard + AiThrottlerGuard and @RequireFeature("AI_CREDIT").
| Method | Path | Description |
|---|---|---|
POST | /ai/tools/hangman-game | Generate session via registry (dynamic) |
GET | /ai/tools/hangman-game/recent | List recent sessions with bestScore |
GET | /ai/tools/hangman-game/:sessionId | Re-fetch session (definitions + slots; terms hidden) |
POST | /ai/tools/hangman-game/:sessionId/guess | Submit a single letter guess (server-authoritative) |
POST | /ai/tools/hangman-game/:sessionId/hint | Spend one hint token; returns { position, letter, hintsRemaining, roundComplete, roundWon, term? } |
POST | /ai/tools/hangman-game/:sessionId/submit | Submit completed attempt; returns score + per-round breakdown |
/recent is declared before /:sessionId so NestJS does not route the literal string "recent" through ParseUUIDPipe.
Server-authoritative gameplay
- Guess (
{ roundIndex, letter }) — server normalisesletterto uppercase A-Z, rejects rounds already complete or letters already guessed, detects occurrences in the storedterm, updatesrevealedPositions(+revealedLetters) or incrementswrongGuesses.termis included in the response only when the round transitions to complete (full reveal = win, 6 wrong = loss). - Hint (
{ roundIndex }) — rejects ifhintTokensRemaining === 0, round complete, or hint already used in the round; picks a random unrevealed letter position, reveals it (treated as guessed, not counted wrong), decrementshintTokensRemaining(session-scoped, max 3), setshintUsed = true.
Scoring formula (recomputed server-side on submit)
round_base = max(10, 100 - wrongGuesses x 15) // no hint
round_base = min(50, 100 - wrongGuesses x 15) // hint used
round_score = round_won ? round_base : 10 // minimum 10 even on loss
multiplier = { easy: 1.0, medium: 1.2, hard: 1.5 }[round.difficulty]
session_score = sum( round_score x multiplier )
final_score = round(session_score)
Client-supplied letter, roundIndex, hintUsed, and round scores are not trusted — the server recomputes the final score from stored round state.
Storage (no migration — reuses FEAT-61 tables)
ai_game_sessions.game_type = "hangman";questionsJSONB stores theStoredHangmanSessionshape (rounds[]with server-onlyterm,definition,difficulty,fixedGlyphs,slotCount, per-round gameplay state, and session-levellivesPerTerm,hintTokensRemaining,startedAt).ai_game_attempts.answersJSONB storesStoredHangmanAttemptRound[](per-round breakdown withtermrevealed).ai_game_attempts.score= final summed session score;total_questions= total rounds.
QA Test Scenarios
| Scenario ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-89-01 | Happy path: generate easy session | Open game, pick owned doc, choose easy, generate | Valid owned documentId, difficulty=easy | Rounds returned with definitions, slot counts, fixed glyphs; term absent from payload |
| FEAT-89-02 | Happy path: generate mixed session | Pick owned course scope, choose mixed, generate | Valid owned course_id, difficulty=mixed | Per-term difficulty distribution roughly 30/50/20; multipliers applied at submit |
| FEAT-89-03 | Correct letter guess | Click a letter present in the term | Letter E in PHOTOSYNTHESIS | { correct: true, revealedPositions: [...all E positions], livesRemaining: 6, roundComplete: false }; term absent |
| FEAT-89-04 | Wrong letter guess | Click a letter not in the term | Letter Z in MITOSIS | { correct: false, livesRemaining: 5, roundComplete: false }; term absent |
| FEAT-89-05 | Round won on final correct guess | Guess the final missing letter | Last unrevealed letter | { correct: true, roundComplete: true, roundWon: true, term: "..." }; round score recorded |
| FEAT-89-06 | Round lost on 6th wrong guess | Guess 6 letters not in the term | 6 wrong letters | { correct: false, livesRemaining: 0, roundComplete: true, roundWon: false, term: "..." }; auto-advance after 2 s |
| FEAT-89-07 | Use a hint then solve | Click Hint, then guess remaining letters | Hint used; correct guesses follow | Random unrevealed letter revealed; hintsRemaining decremented; round score capped at 50 |
| FEAT-89-08 | Validation: hint denied at 0 tokens | Click Hint after all 3 used | Hint when hintsRemaining === 0 | Backend returns 400 Bad Request; UI button disabled |
| FEAT-89-09 | Validation: letter already guessed | Click a letter already used | Repeat letter guess | Backend returns 400; UI button remains disabled |
| FEAT-89-10 | Validation: invalid termCount > 15 | Generate with termCount=20 | Invalid termCount | Backend returns 400 Bad Request |
| FEAT-89-11 | Multi-word / hyphenated / digit term | Generate session, observe round layout | Term contains space, hyphen, or digits | Spaces, hyphens, and digits pre-revealed via fixedGlyphs; player only guesses letters |
| FEAT-89-12 | Backend failure: unprocessed document | Generate from a non-OCR'd doc | Unprocessed owned doc | Backend returns 400 with processing-status message before any AI call |
| FEAT-89-13 | Backend failure: insufficient content | Generate from a doc yielding < 3 valid terms | Doc with too little vocab | Backend throws InternalServerErrorException; UI shows safe error |
| FEAT-89-14 | Authorization: foreign document | Generate using a documentId the user does not own | Foreign documentId | Backend returns 403 Forbidden (scope resolver) |
| FEAT-89-15 | Unauthorized access: no auth | Call any hangman endpoint without auth cookies | No auth | Backend returns 401 Unauthorized |
| FEAT-89-16 | Session expiry/refresh: re-fetch mid-session | Close tab mid-round, call GET /:sessionId | Valid session ID | Returns definitions + slots + current guessedLetters per round; term never present for in-progress rounds |
| FEAT-89-17 | Logout/revocation: submit after session ends | After logout, re-call /:sessionId/submit with stale cookie | Revoked session cookie | Backend returns 401; no attempt recorded |
| FEAT-89-18 | Rate limit / abuse: exceed AI throttle | Spam generation/guess requests past the per-hour limit | Burst of requests | AiThrottlerGuard returns 429 Too Many Requests |
| FEAT-89-19 | Plan enforcement | Free-plan user tries to generate | Insufficient plan | Backend returns plan-access failure; UI shows upgrade guidance |
| FEAT-89-20 | Submit completed session | Complete all rounds, submit | Full attempt | POST /:sessionId/submit returns { attemptId, totalRounds, correctRounds, hintsUsed, finalScore, rounds[] } with terms revealed |
| FEAT-89-21 | Adaptive term count | Generate from a small, medium, and large doc | Docs of varying size | AI generates 5 / 8 / 11 terms respectively |
| FEAT-89-22 | Edge case: definition contains the term | AI hypothetically returns a leaked definition | Definition includes term substring | Backend regenerates or drops that term server-side before persisting |
| FEAT-89-23 | Edge case: lives indicator updates | Trigger 3 wrong guesses | 3 misses | UI removes 3 books from the tower; A11y label updates to "3 lives remaining" |
QA coverage: happy paths for easy and mixed, letter-guess outcomes, hint flow, pre-revealed glyphs, mid-session re-fetch and submit, adaptive/override term counts, validation, ownership and auth enforcement, plan-access failure, rate limiting, and lives-indicator A11y.
Edge Cases
- Very short documents — fewer than 3 suitable terms throws
InternalServerErrorException; 3-4 surfaces a softwarning: "fewer terms than usual"in the public payload and continues. - Definition contains the term — AI prompt forbids it; backend strips/regenerates on detection (case-insensitive alphanumeric substring match).
- Single-letter / two-letter terms — minimum term length is 3; backend filters anything shorter.
- All-caps vs mixed-case terms — normalised to uppercase for storage, display, and comparison.
- Duplicate letters in term — all occurrences revealed simultaneously on a correct guess.
- Hint reveals a letter the player later clicks — letter is already marked guessed; the click is a no-op (server returns
400 letter already guessed). - Round abandoned mid-session — partial attempt is persisted on
submit; incomplete rounds count 10 pts minimum if any guess was made, 0 if untouched. - Player closes tab mid-round — session state is durable in the DB; re-fetch via
GET /:sessionIdresumes guesses. - Non-text / unprocessed documents — return
400 Bad Requestwith a descriptive error before any AI call. - Network failure during a guess — guess is server-authoritative; a failed request leaves stored round state untouched and can be retried.
Notes
- Feature flag: none — gated only by
@RequireFeature("AI_CREDIT")plan access. - Dependencies: FEAT-61 (
BaseAiTool,AiToolRegistryService,ai_game_sessions/ai_game_attempts,DocumentPicker,AiPageShell,useAiTool), FEAT-60 (GeminiGenerationProvider,AiScopeResolverService, plan-access guard), FEAT-31 (CookieAuthGuard), FEAT-39 (PlanAccessGuard,@RequireFeature('AI_CREDIT')). - Known limitations (deferred to Phase 2): player-targeted hint reveal, alternative lives-indicator themes (melting candle / wilting plant / dimming lightbulb), per-round source attribution, per-difficulty
/recentfilters, and dedicated course-page entry points. - The existing FEAT-61 AI architecture is preserved — only session-lifecycle routes were added to the controller; generation flows through
POST /ai/tools/:toolId.
Security Notes
- All routes inherit
CookieAuthGuard + PlanAccessGuard + AiThrottlerGuardat the controller class level and@RequireFeature("AI_CREDIT")per endpoint. - Scope resolution via
AiScopeResolverServiceenforces document/course ownership; an unowneddocumentIdyields403. - Correct-answer letters (
term) are stored server-side only inai_game_sessions.questionsand are never returned in the public session view or guess response until the round transitions to complete. - Hint reveals are server-side; the client never knows answer letters until the round ends or the session is submitted.
- Client-supplied
letter,roundIndex,hintUsed, and round scores are not trusted — the server validates and recomputes from stored state.
Related Pages
/docs/features/61-ai-learning-tools— AI tool registry foundation reused here/docs/features/78-typing-game— sibling AI mini-game/docs/features/90-letter-tile-game— sibling AI mini-game with the closest structural parallel/docs/features/82-ai-chat-tool-conversation-mode— toolbox rail where the Vocab Hangman card lives/docs/features/qa-requirements— QA scenario coverage rules