Feature: AI-Powered Letter Tile Game (Word Unscramble)
Metadata
- Issue ID: FEAT-90
- Status: Done
- Owner: Kzu0-afk
- Related PRs:
90-letter-tile-game->dev(StudyBoostIO/StudyboostV2#90)
Naming note: the user-facing name is Letter Tile Game. Internal identifiers preserve the original draft naming for code consistency:
toolId = "word-unscramble-game", route prefix/ai/tools/word-unscramble-game, andai_game_sessions.game_type = "word-unscramble". "Letter Tile Game" and "Word Unscramble" refer to the same feature.
Overview
FEAT-90 introduces an AI-powered word-unscramble mini-game at /ai/word-unscramble-game. The player is given an AI-generated clue — either a definition or a fill-in-the-blank sentence drawn from their study material — and a pool of shuffled letter tiles. The player drags or clicks tiles into blank slots to form the correct term within a per-round countdown.
The feature extends the FEAT-60 / FEAT-61 AI tool foundation and follows the conventions of FEAT-78 (typing game) and FEAT-80 (crossword game) — no new database tables, no new controllers for generation, shared frontend primitives.
Core product goal
- Turn vocabulary review into active recall by surfacing definitions and fill-in-the-blank cues from a selected document or course scope.
- Keep the game flow fast and low-friction (per-round countdown, snap-to-slot tile interaction, no penalty for shuffling).
- Reuse the centralised AI tool architecture rather than introducing one-off controller, persistence, or frontend patterns.
Canonical feature doc: docs/features/FEAT-90-Letter-Tile-Game.md.
Difficulty levels
| Level | Clue type | Letter pool | Timer |
|---|---|---|---|
easy | Definition only | Exact letters only | 60 s |
medium | Fill-in-the-blank only | Exact letters only | 45 s |
hard | Mixed | Exact letters + 3 random decoys | 30 s |
Adaptive term count
| Document size | Term count |
|---|---|
| Small (< 2 000 chars) | 5 |
| Medium (2 000 - 8 000 chars) | 8 |
| Large (> 8 000 chars) | 10-12 (collapses to 11) |
Optional termCount DTO override (clamped [3, 15]).
Frontend Behavior
Route: /ai/word-unscramble-game — frontend/app/(features)/ai/word-unscramble-game/page.tsx.
Page composed with AiPageShell + DocumentPickerTrigger + useAiTool<WordUnscrambleSession>("word-unscramble-game"). A Setup -> Playing -> Submitting -> Results state machine lives in one "use client" file. The AI hub GAME_TOOLS array gains a Letter Tile Game entry (Shuffle icon, blue accent).
Phase: Setup
- Difficulty picker (
easy/medium/hard) with a one-line description per level. DocumentPickerwithsubmitLabel="Generate Game"(single-doc, multi-doc, or course scope).- Recent-sessions panel via
AiHistoryPanel, listing prior sessions with best score per session (sourced fromGET /recent).
Phase: Playing
Per-round layout: clue at the top, blank slots in the centre, shuffled tile pool below, controls and timer at the bottom.
Letter tile interaction (inline LetterTile / BlankSlot primitives):
- Click tile in pool -> moves to next empty slot (or to a slot the player has armed by clicking it first).
- Click placed tile -> returns it to the pool.
- Drag tile -> drop onto any slot (swap if occupied) or back to the pool.
- Spaces, hyphens, and digits render as fixed non-interactive glyphs between slot groups.
Controls:
- Shuffle — re-randomises unplaced pool tiles; no score penalty.
- Clear — returns all placed tiles to the pool.
- Hint — spends one of 3 session hint tokens to auto-place one server-revealed correct letter.
Timer: per-round countdown (60 / 45 / 30 s by difficulty) with green -> amber (< 50%) -> red (< 20%) transitions and a soft warning pulse at 10 s. On timeout the round auto-submits as 0 pts and advances.
Phase: Results
- Per-round breakdown: clue, correct answer, player's answer, time elapsed, hint used, round score.
- Total score and best-score comparison.
- "Play Again" (new session) / "Back to AI Tools" buttons.
Validation and error handling
- The Hint button is disabled once all 3 session tokens are spent.
- Empty or nonsense arrangements submit as incorrect (0 pts).
- Generation errors (unprocessed document, foreign document, insufficient content) surface as safe inline messages on the Setup screen.
Backend Behavior
Tool: WordUnscrambleGameTool (toolId: "word-unscramble-game")
Location: backend/src/ai/tools/games/word-unscramble-game.tool.ts. Extends BaseAiTool; uses AiScopeResolverService for scope resolution and GeminiGenerationProvider for AI generation. Constants: MAX_SOURCE_CHARACTERS = 14_000, MAX_CHUNKS = 14. Registered in AiModule providers, the AI_TOOL_REGISTRY factory tuple + inject list, and exports.
DTO
backend/src/ai/dto/generate-word-unscramble-game.dto.ts — GenerateWordUnscrambleGameDto with documentId?, scope?, difficulty? (default easy), termCount? (max 15). class-validator decorators (@IsUUID, @IsIn, @IsInt, @Min(3), @Max(15)). DTO validation rejects empty scope, unsupported difficulty, and termCount > 15.
Generation flow (POST /ai/tools/word-unscramble-game)
- Resolve source text via
documentId(single doc) orscope(multi-doc/course) usingAiScopeResolverService. - AI call (Gemini,
responseFormat: "json") extracts{ term, clueType, clue, difficulty }[]. - Post-processing: uppercase + trim each
term; reject terms shorter than 3 letters (after stripping non-letter glyphs), clues containing the answer verbatim, filler vocabulary (same reject list as the crossword tool), and words derived from the document title; deduplicate by term. - Build tile pool per round: shuffled letters of the term (including duplicates). On
hard, 3 random A-Z decoys are appended before shuffling. - If fewer than 3 valid terms remain, throw
InternalServerErrorException("insufficient content"). - Persist the full
StoredUnscrambleSessiontoai_game_sessions.questionsJSONB. Correct-answer letters are never sent to the client until the round is submitted.
Endpoints
All endpoints carry class-level CookieAuthGuard + PlanAccessGuard + AiThrottlerGuard and @RequireFeature("AI_CREDIT").
| Method | Path | Description |
|---|---|---|
POST | /ai/tools/word-unscramble-game | Generate session via registry (dynamic) |
GET | /ai/tools/word-unscramble-game/recent | List recent sessions with bestScore |
GET | /ai/tools/word-unscramble-game/:sessionId | Re-fetch session (tiles + clues; answers hidden) |
POST | /ai/tools/word-unscramble-game/:sessionId/hint | Spend one hint token; returns { roundIndex, slotIndex, letter, hintsRemaining } |
POST | /ai/tools/word-unscramble-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.
Hint token system (server-enforced)
Each session starts with 3 hint tokens. Spending a hint auto-places one correct letter into its exact slot. Using any hint in a round caps that round's score base at 60 pts. Hint usage per round is stored in the attempt JSONB; the server rejects submissions claiming more than 3 total hints used.
Scoring formula (recomputed server-side on submit)
speedBonus = floor(50 x max(0, 1 - timeElapsed / timeLimit))
roundScore = correct ? min(hintUsed ? 60 : 100, 100) + speedBonus : 0
finalScore = sum( roundScore )
Maximum per round 150 (100 base + 50 speed bonus, no hint, instant solve); minimum per correct round 100; hint cap 60 + speedBonus; incorrect / timed-out round 0. Client-supplied playerAnswer, timeElapsed, hintUsed, and round scores are not trusted — the server normalises (uppercase + trim) and recomputes the final score per round.
Storage (no migration — reuses FEAT-61 tables)
ai_game_sessions.game_type = "word-unscramble";questionsJSONB storesStoredUnscrambleRound[](server-only correct-answer letters,clueType,clue,difficulty,tiles).ai_game_attempts.answersJSONB storesStoredUnscrambleAttemptRound[](per-round breakdown).ai_game_attempts.score= final summed score;total_questions= total rounds.
QA Test Scenarios
| Scenario ID | Description | Steps | Input | Expected Result |
|---|---|---|---|---|
| FEAT-90-01 | Happy path: generate easy session | Open game, pick owned doc, choose easy, generate | Valid owned documentId, difficulty=easy | Rounds returned with definition clues, exact-letter tile pools, 60 s timer |
| FEAT-90-02 | Happy path: generate hard session | Pick owned doc, choose hard, generate | Valid owned documentId, difficulty=hard | Each tile pool contains 3 extra random decoy letters; 30 s timer |
| FEAT-90-03 | Solve a round instantly | Place tiles correctly within ~1 s, submit | Correct arrangement | Full speed bonus (+50) applied; round score = 150 |
| FEAT-90-04 | Solve at the time limit | Submit correct answer just before timer hits 0 | Correct arrangement | Speed bonus = 0; round score = 100 |
| FEAT-90-05 | Use a hint then solve correctly | Click Hint, place remaining tiles, submit | Hint used + correct answer | Round base capped at 60; final = 60 + speedBonus |
| FEAT-90-06 | Time runs out without submitting | Let the timer expire | No submission | Round score = 0; auto-advances |
| FEAT-90-07 | Multi-word / hyphenated term | Generate session, observe round layout | Term contains a space or hyphen | Space/hyphen pre-revealed via fixedGlyphs; only letter slots accept tiles |
| FEAT-90-08 | Validation: invalid termCount > 15 | Generate with termCount=20 | Invalid termCount | Backend returns 400 Bad Request via ValidationPipe |
| FEAT-90-09 | Validation: unsupported difficulty | Generate with difficulty=expert | Invalid difficulty | Backend returns 400 Bad Request |
| FEAT-90-10 | Backend failure: unprocessed document | Generate from a non-OCR'd / unprocessed doc | Unprocessed owned doc | Backend returns 400 with processing-status message before any AI call |
| FEAT-90-11 | 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-90-12 | Authorization: foreign document | Generate using a documentId the user does not own | Foreign documentId | Backend returns 403 Forbidden (scope resolver) |
| FEAT-90-13 | Unauthorized access: no auth | Call any letter-tile endpoint without auth cookies | No auth | Backend returns 401 Unauthorized |
| FEAT-90-14 | Rate limit / abuse: hint-token tampering | Submit an attempt claiming more than 3 hints used | Forged hintUsed flags | Server rejects the submission; recomputes hint count from session state |
| FEAT-90-15 | Plan enforcement | Free-plan user tries to generate | Insufficient plan | Backend returns plan-access failure; UI shows upgrade guidance |
| FEAT-90-16 | Re-fetch session mid-play | Call GET /:sessionId mid-session | Valid session ID | Returns tiles + clues; term answers absent from response |
| FEAT-90-17 | Submit full session | Complete all rounds, submit | Full attempt payload | POST /:sessionId/submit returns { attemptId, totalRounds, correctRounds, hintsUsed, finalScore, rounds[] } |
| FEAT-90-18 | Adaptive term count + override | Generate from a small doc, then with termCount=10 | Small doc; termCount=10 | AI generates 5 terms, then exactly 10 terms |
QA coverage: happy paths per difficulty, scoring extremes, multi-word/hyphenated terms, submit and mid-session re-fetch, adaptive/override term counts, validation, ownership/auth enforcement, plan-access failure, and hint-token tamper rejection.
Edge Cases
- Single-letter / two-letter terms — AI prompt enforces a 3-character minimum; backend filters anything shorter.
- Duplicate letters in term — the tile pool includes all duplicates (e.g.
ASSESS-> 3xS). - Case normalisation — terms stored and compared uppercase; player input normalised before scoring.
- Decoy letter collision on
hard— decoys must not accidentally complete the word; backend validates after the AI returns and resamples on collision (no-op stub in v1 since the pool always contains every required letter). - AI returns fewer valid terms than requested — proceed with what was returned (minimum 3); throw
InternalServerErrorExceptionif fewer than 3 are valid. - Session replay — a player can replay the same session; each play creates a new
ai_game_attemptsrow.GET /recentshows best score per session. - Empty / nonsense
playerAnswer— treated as incorrect; round scored 0. - Client tampering with
roundScore/hintUsed— the server recomputes both fromtimeElapsedand stored session state; client values are display-only. - Network failure mid-round — round state is held client-side until submit; a failed submit can be retried, and timeout still auto-submits 0 pts.
- Legacy sessions without
tiles— should not occur (no migration); the controller rejects any session whose JSONB fails theStoredUnscrambleRound[]schema.
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): manual multi-document picking, dedicated course-page entry points, per-round source attribution, per-difficulty
/recentfilters. - v1 uses single-document launch via
DocumentPicker; hint reveal is server-side so the client never knows the answer letters until submit. - 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. The hint endpoint reveals exactly one letter per call, bounded by the server-tracked token count. - Client-supplied
playerAnswer,timeElapsed,hintUsed, and round scores are not trusted — the server normalises and recomputes the final score.
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/89-hangman-game— sibling AI mini-game with the closest structural parallel/docs/features/82-ai-chat-tool-conversation-mode— toolbox rail where the Letter Tile Game card lives/docs/features/qa-requirements— QA scenario coverage rules