StudyBoost Docs

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

LevelTerm profileMultiplier
easyShort, common terms (<= 8 letters); clear definitionsx1.0
mediumModerate-length terms (8-14 letters); standard academic definitionsx1.2
hardLong or technical terms (> 14 letters / compound); dense definitionsx1.5
mixedApprox. 30% easy / 50% medium / 20% hard; per-term multiplierper-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-gamefrontend/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.
  • DocumentPicker with submitLabel="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 indicatorTerm 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 === 0 or 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.tsGenerateHangmanGameDto 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)

  1. Resolve source text via documentId (single doc) or scope (multi-doc/course) using AiScopeResolverService.
  2. AI call (Gemini, responseFormat: "json") extracts { term, definition, difficulty }[].
  3. 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.
  4. Build per-round storage: compute fixedGlyphs (positions of spaces / hyphens / digits), slotCount, and initial state (guessedLetters = [], wrongGuesses = 0, hintUsed = false, roundComplete = false).
  5. If fewer than 3 valid terms remain, throw InternalServerErrorException ("insufficient content for hangman").
  6. Persist the full StoredHangmanSession to ai_game_sessions.questions JSONB. The term field is never returned in any public payload during active play.

Endpoints

All endpoints carry class-level CookieAuthGuard + PlanAccessGuard + AiThrottlerGuard and @RequireFeature("AI_CREDIT").

MethodPathDescription
POST/ai/tools/hangman-gameGenerate session via registry (dynamic)
GET/ai/tools/hangman-game/recentList recent sessions with bestScore
GET/ai/tools/hangman-game/:sessionIdRe-fetch session (definitions + slots; terms hidden)
POST/ai/tools/hangman-game/:sessionId/guessSubmit a single letter guess (server-authoritative)
POST/ai/tools/hangman-game/:sessionId/hintSpend one hint token; returns { position, letter, hintsRemaining, roundComplete, roundWon, term? }
POST/ai/tools/hangman-game/:sessionId/submitSubmit 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 normalises letter to uppercase A-Z, rejects rounds already complete or letters already guessed, detects occurrences in the stored term, updates revealedPositions (+ revealedLetters) or increments wrongGuesses. term is included in the response only when the round transitions to complete (full reveal = win, 6 wrong = loss).
  • Hint ({ roundIndex }) — rejects if hintTokensRemaining === 0, round complete, or hint already used in the round; picks a random unrevealed letter position, reveals it (treated as guessed, not counted wrong), decrements hintTokensRemaining (session-scoped, max 3), sets hintUsed = 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"; questions JSONB stores the StoredHangmanSession shape (rounds[] with server-only term, definition, difficulty, fixedGlyphs, slotCount, per-round gameplay state, and session-level livesPerTerm, hintTokensRemaining, startedAt).
  • ai_game_attempts.answers JSONB stores StoredHangmanAttemptRound[] (per-round breakdown with term revealed).
  • ai_game_attempts.score = final summed session score; total_questions = total rounds.

QA Test Scenarios

Scenario IDDescriptionStepsInputExpected Result
FEAT-89-01Happy path: generate easy sessionOpen game, pick owned doc, choose easy, generateValid owned documentId, difficulty=easyRounds returned with definitions, slot counts, fixed glyphs; term absent from payload
FEAT-89-02Happy path: generate mixed sessionPick owned course scope, choose mixed, generateValid owned course_id, difficulty=mixedPer-term difficulty distribution roughly 30/50/20; multipliers applied at submit
FEAT-89-03Correct letter guessClick a letter present in the termLetter E in PHOTOSYNTHESIS{ correct: true, revealedPositions: [...all E positions], livesRemaining: 6, roundComplete: false }; term absent
FEAT-89-04Wrong letter guessClick a letter not in the termLetter Z in MITOSIS{ correct: false, livesRemaining: 5, roundComplete: false }; term absent
FEAT-89-05Round won on final correct guessGuess the final missing letterLast unrevealed letter{ correct: true, roundComplete: true, roundWon: true, term: "..." }; round score recorded
FEAT-89-06Round lost on 6th wrong guessGuess 6 letters not in the term6 wrong letters{ correct: false, livesRemaining: 0, roundComplete: true, roundWon: false, term: "..." }; auto-advance after 2 s
FEAT-89-07Use a hint then solveClick Hint, then guess remaining lettersHint used; correct guesses followRandom unrevealed letter revealed; hintsRemaining decremented; round score capped at 50
FEAT-89-08Validation: hint denied at 0 tokensClick Hint after all 3 usedHint when hintsRemaining === 0Backend returns 400 Bad Request; UI button disabled
FEAT-89-09Validation: letter already guessedClick a letter already usedRepeat letter guessBackend returns 400; UI button remains disabled
FEAT-89-10Validation: invalid termCount > 15Generate with termCount=20Invalid termCountBackend returns 400 Bad Request
FEAT-89-11Multi-word / hyphenated / digit termGenerate session, observe round layoutTerm contains space, hyphen, or digitsSpaces, hyphens, and digits pre-revealed via fixedGlyphs; player only guesses letters
FEAT-89-12Backend failure: unprocessed documentGenerate from a non-OCR'd docUnprocessed owned docBackend returns 400 with processing-status message before any AI call
FEAT-89-13Backend failure: insufficient contentGenerate from a doc yielding < 3 valid termsDoc with too little vocabBackend throws InternalServerErrorException; UI shows safe error
FEAT-89-14Authorization: foreign documentGenerate using a documentId the user does not ownForeign documentIdBackend returns 403 Forbidden (scope resolver)
FEAT-89-15Unauthorized access: no authCall any hangman endpoint without auth cookiesNo authBackend returns 401 Unauthorized
FEAT-89-16Session expiry/refresh: re-fetch mid-sessionClose tab mid-round, call GET /:sessionIdValid session IDReturns definitions + slots + current guessedLetters per round; term never present for in-progress rounds
FEAT-89-17Logout/revocation: submit after session endsAfter logout, re-call /:sessionId/submit with stale cookieRevoked session cookieBackend returns 401; no attempt recorded
FEAT-89-18Rate limit / abuse: exceed AI throttleSpam generation/guess requests past the per-hour limitBurst of requestsAiThrottlerGuard returns 429 Too Many Requests
FEAT-89-19Plan enforcementFree-plan user tries to generateInsufficient planBackend returns plan-access failure; UI shows upgrade guidance
FEAT-89-20Submit completed sessionComplete all rounds, submitFull attemptPOST /:sessionId/submit returns { attemptId, totalRounds, correctRounds, hintsUsed, finalScore, rounds[] } with terms revealed
FEAT-89-21Adaptive term countGenerate from a small, medium, and large docDocs of varying sizeAI generates 5 / 8 / 11 terms respectively
FEAT-89-22Edge case: definition contains the termAI hypothetically returns a leaked definitionDefinition includes term substringBackend regenerates or drops that term server-side before persisting
FEAT-89-23Edge case: lives indicator updatesTrigger 3 wrong guesses3 missesUI 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 soft warning: "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 /:sessionId resumes guesses.
  • Non-text / unprocessed documents — return 400 Bad Request with 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 /recent filters, 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 + AiThrottlerGuard at the controller class level and @RequireFeature("AI_CREDIT") per endpoint.
  • Scope resolution via AiScopeResolverService enforces document/course ownership; an unowned documentId yields 403.
  • Correct-answer letters (term) are stored server-side only in ai_game_sessions.questions and 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