feat: AI Gamemaster gamemode (voice-first secret-rule games)#7
Merged
Conversation
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- umbrella-land: cache lastWord() result; guard empty-string case explicitly - types: document acceptedItems contract with JSDoc - captain: broaden isRuleStatement regex to tolerate dropped apostrophes - magic-moon: remove redundant trailing regex alternative - black-magic: add test for missing-list guard - registry: add blank line between final import and GAMES export Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds enforce(), getLimiters(), and identifierFrom() for rate-limiting the three Gamemaster API routes. Limiters are lazily initialised so importing the module never calls Redis.fromEnv() without env vars. Tests inject fake limiters so Redis is never touched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds `narrate()` which calls the Vercel AI Gateway (openai/gpt-4o-mini) to turn the engine's deterministic RuleResponse into in-character Gamemaster patter, with a guaranteed canned fallback on any model error. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/gamemaster/turn validates game ID, delegates verdict to the rule engine (judge), then calls narrate for in-character patter. Rate-limit check runs first; engine — not the model — owns the verdict. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/gamemaster/listen passes raw audio bytes to Whisper via experimental_transcribe and returns the transcript. Rate-limit check runs before any work is done. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
POST /api/gamemaster/speak converts text to speech via experimental_generateSpeech (gpt-4o-mini-tts, ballad voice) and streams raw audio bytes with the provider's mediaType header. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Uint8Array<ArrayBufferLike> is not assignable to BodyInit in the project's TypeScript config; pass .buffer as ArrayBuffer instead. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Fix latent buffer bug: pass Uint8Array view directly to Response instead of .buffer (avoids sending extra bytes from a pooled ArrayBuffer) - Tighten text guard in speak/route.ts to match /turn (typeof check) - Assert engine/persona not reached after 429 in turn, listen, speak tests - Add 502 tests for listen (transcription throws) and speak (TTS throws) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re-record Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te-limit key Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
Pull request overview
Adds a new /gamemaster mode to Quest: a voice-first “secret rule” party-game experience powered by a deterministic rule engine, with AI used only for narration plus STT/TTS routes and Upstash rate limiting.
Changes:
- Introduces a fully unit-tested deterministic rules engine for 9 secret-rule games (
lib/gamemaster/rules/*) with a registry + judge orchestrator. - Adds AI narration (
generateTextvia AI Gateway) plus voice in/out API routes using Vercel AI SDK (experimental_transcribe,experimental_generateSpeech) and a client push-to-talk UI with typed and speechSynthesis fallbacks. - Adds Upstash Redis sliding-window rate limiting for
/turn,/listen, and/speak, plus env scaffolding and a home-nav entry point.
Reviewed changes
Copilot reviewed 42 out of 43 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| package.json | Adds AI SDK + Upstash dependencies for gamemaster mode. |
| package-lock.json | Locks new AI SDK and Upstash dependency graph. |
| .env.example | Documents required env vars for AI Gateway/OpenAI + Upstash rate limiting. |
| app/page.tsx | Adds a nav link to the new /gamemaster surface. |
| app/gamemaster/page.tsx | New gamemaster entry page. |
| app/gamemaster/gamemaster-shell.tsx | Client orchestrator for room routing, game deck, and voice-first turn loop. |
| app/gamemaster/useSpeechInput.ts | Push-to-talk recording + /listen transcription client hook with typed fallback. |
| app/gamemaster/voice-client.ts | /speak TTS client with browser speechSynthesis fallback. |
| app/api/gamemaster/turn/route.ts | Server route that runs deterministic judge + narration, rate-limited. |
| app/api/gamemaster/turn/route.test.ts | Tests for /turn route wiring and rate-limit behavior. |
| app/api/gamemaster/listen/route.ts | Server route for STT transcription, rate-limited. |
| app/api/gamemaster/listen/route.test.ts | Tests for /listen route and error/limit handling. |
| app/api/gamemaster/speak/route.ts | Server route for TTS audio generation, rate-limited. |
| app/api/gamemaster/speak/route.test.ts | Tests for /speak route and error/limit handling. |
| lib/gamemaster/rate-limit.ts | Upstash Redis rate limiting helper (lazily initialized). |
| lib/gamemaster/rate-limit.test.ts | Unit tests for rate limiting helper behavior. |
| lib/gamemaster/gamemaster.ts | AI persona narration layer with deterministic fallback lines. |
| lib/gamemaster/gamemaster.test.ts | Tests for narration fallback behavior and AI call wiring. |
| lib/gamemaster/rules/types.ts | Rule engine types + shared word helpers (pure/deterministic). |
| lib/gamemaster/rules/types.test.ts | Unit tests for word helper utilities. |
| lib/gamemaster/rules/magic-moon.ts | Magic Moon rule implementation. |
| lib/gamemaster/rules/magic-moon.test.ts | Tests for Magic Moon rule + win-statement recognition. |
| lib/gamemaster/rules/umbrella-land.ts | Umbrella Land rule implementation. |
| lib/gamemaster/rules/umbrella-land.test.ts | Tests for Umbrella Land rule + rule-statement recognition. |
| lib/gamemaster/rules/captain.ts | The Captain rule implementation. |
| lib/gamemaster/rules/captain.test.ts | Tests for The Captain rule + rule-statement recognition. |
| lib/gamemaster/rules/pineapples.ts | Pineapples rule implementation. |
| lib/gamemaster/rules/pineapples.test.ts | Tests for Pineapples rule + rule-statement recognition. |
| lib/gamemaster/rules/open-and-closed.ts | Open/Closed rule implementation (vowel-start heuristic). |
| lib/gamemaster/rules/open-and-closed.test.ts | Tests for Open/Closed rule + rule-statement recognition. |
| lib/gamemaster/rules/triangle.ts | Triangle rule implementation (throwaway word first). |
| lib/gamemaster/rules/triangle.test.ts | Tests for Triangle rule + rule-statement recognition. |
| lib/gamemaster/rules/albert-the-frog.ts | Albert the Frog rule implementation (ascending length). |
| lib/gamemaster/rules/albert-the-frog.test.ts | Tests for Albert the Frog context-dependent behavior. |
| lib/gamemaster/rules/horses.ts | Horses rule implementation (letters-in-question value). |
| lib/gamemaster/rules/horses.test.ts | Tests for Horses value response + rule-statement recognition. |
| lib/gamemaster/rules/black-magic.ts | Black Magic rule implementation (item after black). |
| lib/gamemaster/rules/black-magic.test.ts | Tests for Black Magic list-dependent value response. |
| lib/gamemaster/rules/registry.ts | Central registry of all nine games. |
| lib/gamemaster/rules/judge.ts | Thin judge orchestrator delegating to selected game. |
| lib/gamemaster/rules/judge.test.ts | Tests for registry completeness and judge behavior. |
| docs/superpowers/specs/2026-06-01-gamemaster-mode-design.md | Design spec describing architecture, UX, and constraints. |
| docs/superpowers/plans/2026-06-01-gamemaster-mode.md | Implementation plan outlining tasks and testing strategy. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+27
to
+29
| if (!(body.gameId in GAMES)) { | ||
| return Response.json({ error: "unknown_game" }, { status: 400 }); | ||
| } |
Comment on lines
+10
to
+11
| const audio = new Uint8Array(await req.arrayBuffer()); | ||
| const { text } = await transcribe({ model: openai.transcription("whisper-1"), audio }); |
Comment on lines
+12
to
+14
| if (typeof body.text !== "string" || !body.text) { | ||
| return Response.json({ error: "missing_text" }, { status: 400 }); | ||
| } |
Comment on lines
+45
to
+48
| const stop = useCallback(() => { | ||
| recorderRef.current?.stop(); | ||
| recorderRef.current = null; | ||
| }, []); |
Comment on lines
+73
to
+88
| const data = (await res.json()) as { text: string; accepted: boolean; response: TurnResponse }; | ||
| setTurns((t) => [...t, { who: "gamemaster", text: data.text }]); | ||
| if (res.status === 429) { | ||
| await speak(data.text); | ||
| return; | ||
| } | ||
| if (data.response?.kind === "verdict" && data.response.fits) { | ||
| // Mirror the engine's lastWord(): lowercase, alphabetic-only tokens, | ||
| // so Albert the Frog's length comparison matches what the engine sees. | ||
| const item = utterance.toLowerCase().split(/[^a-z]+/).filter(Boolean).pop() ?? ""; | ||
| setAccepted((a) => [...a, item]); | ||
| setWrongStreak(0); | ||
| } else if (data.response?.kind === "verdict") { | ||
| setWrongStreak((w) => w + 1); | ||
| } | ||
| await speak(data.text); |
Comment on lines
+10
to
+11
| const audio = new Uint8Array(await req.arrayBuffer()); | ||
| const { text } = await transcribe({ model: openai.transcription("whisper-1"), audio }); |
Comment on lines
+12
to
+14
| if (typeof body.text !== "string" || !body.text) { | ||
| return Response.json({ error: "missing_text" }, { status: 400 }); | ||
| } |
Comment on lines
+45
to
+48
| const stop = useCallback(() => { | ||
| recorderRef.current?.stop(); | ||
| recorderRef.current = null; | ||
| }, []); |
Comment on lines
+73
to
+88
| const data = (await res.json()) as { text: string; accepted: boolean; response: TurnResponse }; | ||
| setTurns((t) => [...t, { who: "gamemaster", text: data.text }]); | ||
| if (res.status === 429) { | ||
| await speak(data.text); | ||
| return; | ||
| } | ||
| if (data.response?.kind === "verdict" && data.response.fits) { | ||
| // Mirror the engine's lastWord(): lowercase, alphabetic-only tokens, | ||
| // so Albert the Frog's length comparison matches what the engine sees. | ||
| const item = utterance.toLowerCase().split(/[^a-z]+/).filter(Boolean).pop() ?? ""; | ||
| setAccepted((a) => [...a, item]); | ||
| setWrongStreak(0); | ||
| } else if (data.response?.kind === "verdict") { | ||
| setWrongStreak((w) => w + 1); | ||
| } | ||
| await speak(data.text); |
Comment on lines
13
to
20
| "dependencies": { | ||
| "@ai-sdk/openai": "^3.0.67", | ||
| "@neondatabase/serverless": "^1.1.0", | ||
| "@upstash/ratelimit": "^2.0.8", | ||
| "@upstash/redis": "^1.38.0", | ||
| "@vercel/blob": "^2.4.0", | ||
| "ai": "^6.0.193", | ||
| "drizzle-orm": "^0.45.2", |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
/gamemastergamemode: FreeRooms routes a group to a real empty campus room, then an AI Gamemaster runs 9 classic "secret-rule" deduction games (Magic Moon, Umbrella Land, The Captain, Pineapples, Open & Closed, Triangle, Albert the Frog, Horses, Black Magic).lib/gamemaster/rules/) decides every verdict/value and the win check — the AI (generateTextvia AI Gateway) only narrates, so it can never override a verdict. Canned fallback when the model is unavailable.experimental_transcribe(/api/gamemaster/listen) for player input andexperimental_generateSpeech(/api/gamemaster/speak) for the Gamemaster's voice, with a browserspeechSynthesisfallback. Push-to-talk client with a typed fallback.docs/superpowers/.Built with brainstorming → spec → plan → subagent-driven TDD. 137 tests pass; the rule engine is fully unit-tested.
Test Plan
npm run test:run— 137 passed (32 files)npx tsc --noEmitclean;eslintclean on all new code.env.local:AI_GATEWAY_API_KEY,OPENAI_API_KEY,UPSTASH_REDIS_REST_URL,UPSTASH_REDIS_REST_TOKEN/gamemaster(iPhone 14–16 / Firefox responsive): find-room → deck → pick Magic Moon → hold-to-speak "a balloon" (accept) / "the sun" (reject); typed fallback when mic deniedgpt-4o-mini-ttsmodel +balladvoice against the OpenAI account🤖 Generated with Claude Code