StudyBoost Docs

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, and ai_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

LevelClue typeLetter poolTimer
easyDefinition onlyExact letters only60 s
mediumFill-in-the-blank onlyExact letters only45 s
hardMixedExact letters + 3 random decoys30 s

Adaptive term count

Document sizeTerm 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-gamefrontend/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.
  • DocumentPicker with submitLabel="Generate Game" (single-doc, multi-doc, or course scope).
  • Recent-sessions panel via AiHistoryPanel, listing prior sessions with best score per session (sourced from GET /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.tsGenerateWordUnscrambleGameDto 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)

  1. Resolve source text via documentId (single doc) or scope (multi-doc/course) using AiScopeResolverService.
  2. AI call (Gemini, responseFormat: "json") extracts { term, clueType, clue, difficulty }[].
  3. 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.
  4. Build tile pool per round: shuffled letters of the term (including duplicates). On hard, 3 random A-Z decoys are appended before shuffling.
  5. If fewer than 3 valid terms remain, throw InternalServerErrorException ("insufficient content").
  6. Persist the full StoredUnscrambleSession to ai_game_sessions.questions JSONB. 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").

MethodPathDescription
POST/ai/tools/word-unscramble-gameGenerate session via registry (dynamic)
GET/ai/tools/word-unscramble-game/recentList recent sessions with bestScore
GET/ai/tools/word-unscramble-game/:sessionIdRe-fetch session (tiles + clues; answers hidden)
POST/ai/tools/word-unscramble-game/:sessionId/hintSpend one hint token; returns { roundIndex, slotIndex, letter, hintsRemaining }
POST/ai/tools/word-unscramble-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.

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"; questions JSONB stores StoredUnscrambleRound[] (server-only correct-answer letters, clueType, clue, difficulty, tiles).
  • ai_game_attempts.answers JSONB stores StoredUnscrambleAttemptRound[] (per-round breakdown).
  • ai_game_attempts.score = final summed score; total_questions = total rounds.

QA Test Scenarios

Scenario IDDescriptionStepsInputExpected Result
FEAT-90-01Happy path: generate easy sessionOpen game, pick owned doc, choose easy, generateValid owned documentId, difficulty=easyRounds returned with definition clues, exact-letter tile pools, 60 s timer
FEAT-90-02Happy path: generate hard sessionPick owned doc, choose hard, generateValid owned documentId, difficulty=hardEach tile pool contains 3 extra random decoy letters; 30 s timer
FEAT-90-03Solve a round instantlyPlace tiles correctly within ~1 s, submitCorrect arrangementFull speed bonus (+50) applied; round score = 150
FEAT-90-04Solve at the time limitSubmit correct answer just before timer hits 0Correct arrangementSpeed bonus = 0; round score = 100
FEAT-90-05Use a hint then solve correctlyClick Hint, place remaining tiles, submitHint used + correct answerRound base capped at 60; final = 60 + speedBonus
FEAT-90-06Time runs out without submittingLet the timer expireNo submissionRound score = 0; auto-advances
FEAT-90-07Multi-word / hyphenated termGenerate session, observe round layoutTerm contains a space or hyphenSpace/hyphen pre-revealed via fixedGlyphs; only letter slots accept tiles
FEAT-90-08Validation: invalid termCount > 15Generate with termCount=20Invalid termCountBackend returns 400 Bad Request via ValidationPipe
FEAT-90-09Validation: unsupported difficultyGenerate with difficulty=expertInvalid difficultyBackend returns 400 Bad Request
FEAT-90-10Backend failure: unprocessed documentGenerate from a non-OCR'd / unprocessed docUnprocessed owned docBackend returns 400 with processing-status message before any AI call
FEAT-90-11Backend failure: insufficient contentGenerate from a doc yielding < 3 valid termsDoc with too little vocabBackend throws InternalServerErrorException; UI shows safe error
FEAT-90-12Authorization: foreign documentGenerate using a documentId the user does not ownForeign documentIdBackend returns 403 Forbidden (scope resolver)
FEAT-90-13Unauthorized access: no authCall any letter-tile endpoint without auth cookiesNo authBackend returns 401 Unauthorized
FEAT-90-14Rate limit / abuse: hint-token tamperingSubmit an attempt claiming more than 3 hints usedForged hintUsed flagsServer rejects the submission; recomputes hint count from session state
FEAT-90-15Plan enforcementFree-plan user tries to generateInsufficient planBackend returns plan-access failure; UI shows upgrade guidance
FEAT-90-16Re-fetch session mid-playCall GET /:sessionId mid-sessionValid session IDReturns tiles + clues; term answers absent from response
FEAT-90-17Submit full sessionComplete all rounds, submitFull attempt payloadPOST /:sessionId/submit returns { attemptId, totalRounds, correctRounds, hintsUsed, finalScore, rounds[] }
FEAT-90-18Adaptive term count + overrideGenerate from a small doc, then with termCount=10Small doc; termCount=10AI 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 -> 3x S).
  • 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 InternalServerErrorException if fewer than 3 are valid.
  • Session replay — a player can replay the same session; each play creates a new ai_game_attempts row. GET /recent shows best score per session.
  • Empty / nonsense playerAnswer — treated as incorrect; round scored 0.
  • Client tampering with roundScore / hintUsed — the server recomputes both from timeElapsed and 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 the StoredUnscrambleRound[] 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 /recent filters.
  • 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 + 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. 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