Add Word Wrangler demos

This commit is contained in:
Mark Backman
2025-04-24 14:29:19 -04:00
parent 09ff836ef6
commit c80d09f66c
54 changed files with 12209 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

View File

@@ -0,0 +1,19 @@
export function Card({
children,
className,
}: {
children: React.ReactNode;
className?: string;
}) {
return (
<div
className={`bg-white rounded-3xl relative card-border text-black ${className}`}
>
{children}
</div>
);
}
export function CardInner({ children }: { children: React.ReactNode }) {
return <div className="p-6 lg:p-10 relative z-2">{children}</div>;
}

View File

@@ -0,0 +1,92 @@
import { GAME_STATES, GAME_TEXT, GameState } from "@/constants/gameConstants";
import { IconArrowForwardUp, IconClockPause } from "@tabler/icons-react";
import React from "react";
import { GameWord } from "./GameWord";
import { Timer } from "./Timer";
import styles from "./WordWrangler.module.css";
interface GameContentProps {
gameState: GameState;
currentWord: string;
showAutoDetected: boolean;
timeLeft: number;
showIncorrect: boolean;
score: number;
skipsRemaining: number;
// onCorrect: () => void;
onSkip: () => void;
}
export const GameContent: React.FC<GameContentProps> = ({
gameState,
currentWord,
showAutoDetected,
showIncorrect,
timeLeft,
score,
skipsRemaining,
//onCorrect,
onSkip,
}) => {
// Idle or Connecting State
if (gameState === GAME_STATES.IDLE || gameState === GAME_STATES.CONNECTING) {
return (
<div className={styles.simpleLoadingPlaceholder}>
{GAME_TEXT.startingGame}
</div>
);
}
// Waiting for Intro State
if (gameState === GAME_STATES.WAITING_FOR_INTRO) {
return (
<div className="animate-pulse flex flex-1 flex-col gap-3 items-center justify-center">
<span className="size-18 flex items-center justify-center rounded-full bg-slate-900/50 text-white">
<IconClockPause size={42} className="opacity-50" />
</span>
<span className="text-white text-2xl font-bold">
{GAME_TEXT.waitingForIntro}
</span>
</div>
);
}
// Finished State
if (gameState === GAME_STATES.FINISHED) {
return (
<div className={styles.gameReadyArea}>
<div className={styles.gameResults}>
<h2>{GAME_TEXT.gameOver}</h2>
<p>
{GAME_TEXT.finalScore}: <strong>{score}</strong>
</p>
</div>
<div className={styles.statusNote}>{GAME_TEXT.clickToStart}</div>
</div>
);
}
// Active Game State
return (
<div className={styles.gameArea}>
<GameWord
word={currentWord}
showAutoDetected={showAutoDetected}
showIncorrect={showIncorrect}
/>
<div className="flex flex-col lg:flex-row gap-2 w-full">
<Timer timeLeft={timeLeft} gameState={gameState} />
<button
className="button"
onClick={onSkip}
disabled={skipsRemaining <= 0}
>
<IconArrowForwardUp size={24} className="opacity-50" />
{skipsRemaining > 0
? GAME_TEXT.skipsRemaining(skipsRemaining)
: GAME_TEXT.noSkips}
</button>
</div>
</div>
);
};

View File

@@ -0,0 +1,79 @@
import { GAME_TEXT } from "@/constants/gameConstants";
import React from "react";
import styles from "./WordWrangler.module.css";
interface GameWordProps {
word: string;
showAutoDetected: boolean;
showIncorrect: boolean;
}
export const GameWord: React.FC<GameWordProps> = ({
word,
showAutoDetected,
showIncorrect,
}) => {
return (
<div
className={`${styles.currentWord} ${
showAutoDetected ? styles.correctWordDetected : ""
} ${showIncorrect ? styles.incorrectWordDetected : ""}`}
>
<span className={styles.helpText}>{GAME_TEXT.describeWord}</span>
<span className={styles.word}>{word}</span>
{showAutoDetected && <CorrectOverlay />}
{showIncorrect && <IncorrectOverlay />}
</div>
);
};
const CorrectOverlay: React.FC = () => (
<div className={styles.autoDetectedOverlay}>
<div className={styles.checkmarkContainer}>
<svg
className={styles.checkmarkSvg}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
>
<circle
className={styles.checkmarkCircle}
cx="26"
cy="26"
r="25"
fill="none"
/>
<path
className={styles.checkmarkCheck}
fill="none"
d="M14.1 27.2l7.1 7.2 16.7-16.8"
/>
</svg>
</div>
</div>
);
const IncorrectOverlay: React.FC = () => (
<div className={styles.incorrectOverlay}>
<div className={styles.xmarkContainer}>
<svg
className={styles.xmarkSvg}
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 52 52"
>
<circle
className={styles.xmarkCircle}
cx="26"
cy="26"
r="25"
fill="none"
/>
<path
className={styles.xmarkX}
fill="none"
d="M16 16 L36 36 M36 16 L16 36"
/>
</svg>
</div>
</div>
);

View File

@@ -0,0 +1,11 @@
.divider {
width: 100%;
height: 2px;
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.15) 30%,
rgba(255, 255, 255, 0.15) 70%,
transparent 100%
);
}

View File

@@ -0,0 +1,44 @@
import { IconLaurelWreathFilled, IconStarFilled } from "@tabler/icons-react";
import styles from "./ScoreRow.module.css";
interface ScoreRowProps {
score: number;
bestScore: number;
}
export function ScoreRow({ score, bestScore = 0 }: ScoreRowProps) {
return (
<div className="flex flex-col w-full lg:w-auto justify-between gap-3 lg:gap-5">
<div className="flex flex-1 w-full lg:w-auto flex-row items-center gap-3 lg:gap-5 text-white bg-black/20 rounded-2xl lg:rounded-3xl px-4 py-3 lg:px-6 lg:py-4">
<IconStarFilled
size={42}
className="text-amber-300 size-8 lg:size-10"
/>
<div className="flex flex-col gap-1">
<span className="text-xs lg:text-sm uppercase font-extrabold tracking-wider">
Current score
</span>
<span className="text-xl lg:text-2xl font-extrabold leading-none">
{score}
</span>
</div>
</div>
<div className={styles.divider} />
<div className="flex flex-row items-center gap-5 text-white rounded-3xl px-6">
<IconLaurelWreathFilled
size={42}
className="text-amber-300 size-8 lg:size-10"
/>
<div className="flex flex-col gap-1">
<span className="text-xs lg:text-sm uppercase font-extrabold tracking-wider">
Best score
</span>
<span className="text-xl lg:text-2xl font-extrabold leading-none">
{bestScore}
</span>
</div>
</div>
</div>
);
}
export default ScoreRow;

View File

@@ -0,0 +1,30 @@
import { GAME_CONFIG, GAME_STATES } from "@/constants/gameConstants";
import { formatTime } from "@/utils/formatTime";
import { IconStopwatch } from "@tabler/icons-react";
import styles from "./WordWrangler.module.css";
interface TimerProps {
timeLeft: number;
gameState: string;
}
export function Timer({ timeLeft, gameState }: TimerProps) {
const lowTimer =
gameState === GAME_STATES.ACTIVE &&
timeLeft <= GAME_CONFIG.LOW_TIME_WARNING;
return (
<div className={`${styles.timer} ${lowTimer ? styles.lowTime : ""}`}>
<div className={styles.timerBadge}>
<IconStopwatch size={24} />
<span>{formatTime(timeLeft)}</span>
</div>
<div className={styles.timerBar}>
<div
className={styles.timerBarFill}
style={{ width: `${(timeLeft / GAME_CONFIG.GAME_DURATION) * 100}%` }}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,589 @@
.gameContainer {
position: relative;
z-index: 1;
padding: 4px;
width: 100%;
border-radius: 28px;
margin-top: 50px;
min-height: 300px;
box-shadow: 0px 66px 26px rgba(0, 0, 0, 0.01),
0px 37px 22px rgba(0, 0, 0, 0.05), 0px 16px 16px rgba(0, 0, 0, 0.09),
0px 4px 9px rgba(0, 0, 0, 0.1);
}
@media (min-width: 1024px) {
.gameContainer {
width: auto;
flex: none;
min-width: 626px;
height: 260px;
margin-top: 0;
}
}
.gameContainer:before {
content: "";
position: absolute;
inset: -4px -4px -8px -4px;
border-radius: 28px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.15) 100%
);
z-index: -1;
}
.gameContainer:after {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0;
border-radius: var(--border-radius-card);
border: var(--border-width-card) solid transparent;
background-image: linear-gradient(#001146, #0655cc),
linear-gradient(
180deg,
var(--theme-gradient-start) 0%,
var(--theme-gradient-end) 100%
);
background-origin: border-box;
background-clip: padding-box, border-box;
}
.gameContent {
position: relative;
z-index: 1;
background: transparent;
border-radius: 20px;
width: 100%;
height: 100%;
min-height: 292px;
display: flex;
overflow: hidden;
border: 6px solid rgba(0, 0, 0, 0.25);
}
.gameContent:after {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(
70% 40% at 50% 40%,
#2da6ee 0%,
rgba(45, 166, 238, 0) 100%
);
opacity: 0.76;
z-index: -1;
}
.gameArea {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 12px;
position: relative;
z-index: 2;
}
.timer {
height: var(--button-height);
border-radius: 9999px;
width: 100%;
flex-direction: row;
gap: 12px;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.2);
padding: 12px;
@media (min-width: 1024px) {
flex: 1;
}
.timerBadge {
display: flex;
flex-direction: row;
align-items: center;
gap: 6px;
background-color: black;
border-radius: 9999px;
color: white;
height: 100%;
padding: 0 12px;
font-weight: 800;
}
.timerBar {
height: 100%;
width: 100%;
border-radius: 9999px;
overflow: hidden;
background-color: var(--color-emerald-100);
}
.timerBarFill {
height: 100%;
width: 100%;
background-color: var(--color-emerald-400);
transition: width 0.3s ease;
}
&.lowTime {
color: #e74c3c;
animation: pulse 1s infinite;
.timerBar {
background-color: var(--color-orange-100);
}
.timerBarFill {
background-color: var(--color-orange-400);
}
}
}
.scoreDisplay {
font-size: 1.25rem;
font-weight: 500;
color: #0071e3;
}
.currentWord {
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
width: 100%;
margin-top: 50px;
.helpText {
font-size: 1rem;
font-weight: 700;
color: rgba(255, 255, 255, 0.5);
}
.word {
font-size: 2rem;
font-weight: 800;
letter-spacing: 0.05em;
line-height: 2;
color: #ffffff;
text-shadow: 0px 4px 0px rgba(0, 0, 0, 0.45);
}
@media (min-width: 1024px) {
margin-top: 0;
.word {
font-size: 3rem;
text-shadow: 0px 6px 0px rgba(0, 0, 0, 0.45);
}
}
}
.gameButton {
padding: 0.85rem 0;
font-size: 1.1rem;
font-weight: 500;
border: none;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s ease;
}
/* Primary button (Skip) */
.skipButton {
flex: 2; /* Takes more space */
background-color: #e74c3c;
color: white;
}
.skipButton:hover {
background-color: #c0392b;
transform: translateY(-2px);
}
/* Secondary button (Correct) - more subdued */
.correctButton {
flex: 1; /* Takes less space */
background-color: #f5f5f7; /* Light gray background */
color: #333; /* Dark text */
border: 1px solid #ddd; /* Subtle border */
}
.correctButton:hover {
background-color: #e8e8ed;
transform: translateY(-1px);
}
.gameReadyArea {
display: flex;
flex-direction: column;
align-items: center;
}
.gameResults {
margin-bottom: 1rem;
padding: 0.75rem;
background-color: #f8f9fa;
border-radius: 8px;
width: 100%;
text-align: center;
}
.gameResults h2 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.3rem;
}
.statusNote {
margin: 0.5rem 0;
padding: 0.6rem 1rem;
background-color: #f8f9fa;
border-left: 3px solid #0071e3;
font-size: 0.95rem;
color: #333;
width: 100%;
text-align: center;
border-radius: 4px;
}
.compactInstructions {
margin: 0.75rem 0;
width: 100%;
max-width: 400px;
background-color: #f8f9fa;
border-radius: 8px;
padding: 0.75rem 1rem;
}
.compactInstructions h3 {
margin: 0 0 0.5rem 0;
color: #333;
font-size: 1.1rem;
text-align: center;
}
.compactInstructions ul {
margin: 0;
padding-left: 1.5rem;
line-height: 1.4;
}
.compactInstructions li {
margin-bottom: 0.4rem;
font-size: 0.9rem;
}
.loadingDots {
display: inline-block;
animation: dotPulse 1.5s infinite linear;
}
@keyframes dotPulse {
0% {
opacity: 0.2;
}
20% {
opacity: 1;
}
100% {
opacity: 0.2;
}
}
@keyframes pulse {
0% {
opacity: 0.8;
}
50% {
opacity: 1;
}
100% {
opacity: 0.8;
}
}
/* Animation styles */
.correctWordDetected {
animation: correctPulse 1.5s ease-in-out;
position: relative;
}
.autoDetectedOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(46, 204, 113, 0.6);
border-radius: 8px;
animation: fadeIn 0.3s ease-in-out;
z-index: 10;
}
.checkmarkContainer {
width: 80px;
height: 80px;
animation: scaleUp 0.4s ease-out;
}
.checkmarkSvg {
width: 100%;
height: 100%;
border-radius: 50%;
display: block;
stroke-width: 4;
stroke: #fff;
stroke-miterlimit: 10;
box-shadow: 0 0 0 rgba(46, 204, 113, 0.7);
animation: fillCheck 0.3s ease-in-out 0.3s forwards,
scale 0.2s ease-in-out 0.7s both;
}
.checkmarkCircle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 4;
stroke-miterlimit: 10;
stroke: #fff;
fill: transparent;
animation: strokeCheck 0.5s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.checkmarkCheck {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: strokeCheck 0.25s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes strokeCheck {
100% {
stroke-dashoffset: 0;
}
}
@keyframes fillCheck {
100% {
box-shadow: inset 0 0 0 50px transparent;
}
}
@keyframes correctPulse {
0% {
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0.7);
}
50% {
box-shadow: 0 0 0 15px rgba(46, 204, 113, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(46, 204, 113, 0);
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes scaleUp {
from {
transform: scale(0.5);
opacity: 0;
}
to {
transform: scale(1);
opacity: 1;
}
}
.incorrectWordDetected {
animation: incorrectPulse 1.5s ease-in-out,
shake 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
position: relative;
}
.incorrectOverlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(255, 59, 48, 0.6); /* Red with transparency */
border-radius: 8px;
animation: fadeIn 0.3s ease-in-out;
z-index: 10;
}
.xmarkContainer {
width: 80px;
height: 80px;
animation: scaleUp 0.4s ease-out;
}
.xmarkSvg {
width: 100%;
height: 100%;
border-radius: 50%;
display: block;
stroke-width: 4;
stroke: #fff;
stroke-miterlimit: 10;
box-shadow: 0 0 0 rgba(255, 59, 48, 0.7);
animation: fillX 0.3s ease-in-out 0.3s forwards,
scale 0.2s ease-in-out 0.7s both;
}
.xmarkCircle {
stroke-dasharray: 166;
stroke-dashoffset: 166;
stroke-width: 4;
stroke-miterlimit: 10;
stroke: #fff;
fill: transparent;
animation: strokeX 0.5s cubic-bezier(0.65, 0, 0.45, 1) forwards;
}
.xmarkX {
transform-origin: 50% 50%;
stroke-dasharray: 48;
stroke-dashoffset: 48;
animation: strokeX 0.25s cubic-bezier(0.65, 0, 0.45, 1) 0.6s forwards;
}
@keyframes strokeX {
100% {
stroke-dashoffset: 0;
}
}
@keyframes fillX {
100% {
box-shadow: inset 0 0 0 50px transparent;
}
}
@keyframes incorrectPulse {
0% {
box-shadow: 0 0 0 0 rgba(255, 59, 48, 0.7);
}
50% {
box-shadow: 0 0 0 15px rgba(255, 59, 48, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(255, 59, 48, 0);
}
}
@keyframes scale {
0%,
100% {
transform: none;
}
50% {
transform: scale3d(1.1, 1.1, 1);
}
}
@keyframes shake {
10%,
90% {
transform: translate3d(-1px, 0, 0);
}
20%,
80% {
transform: translate3d(2px, 0, 0);
}
30%,
50%,
70% {
transform: translate3d(-3px, 0, 0);
}
40%,
60% {
transform: translate3d(3px, 0, 0);
}
}
/* Game loading UI styles */
.gameLoadingContainer {
display: flex;
justify-content: center;
align-items: center;
height: 250px; /* Fixed height to prevent layout shifts */
width: 100%;
}
.gameLoadingContent {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1.5rem;
text-align: center;
}
.gameLoadingIcon {
position: relative;
width: 60px;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
.pulseDot {
width: 16px;
height: 16px;
background-color: #0071e3;
border-radius: 50%;
position: relative;
}
.pulseDot:before {
content: "";
position: absolute;
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #0071e3;
opacity: 0.7;
animation: pulse-wave 1.5s linear infinite;
}
.gameLoadingTitle {
font-size: 1.5rem;
font-weight: 500;
color: #0071e3;
margin: 0;
}
@keyframes pulse-wave {
0% {
transform: scale(1);
opacity: 0.7;
}
50% {
transform: scale(2.5);
opacity: 0;
}
100% {
transform: scale(1);
opacity: 0;
}
}

View File

@@ -0,0 +1,227 @@
import { GAME_STATES, GAME_TEXT } from "@/constants/gameConstants";
import { useConnectionState } from "@/hooks/useConnectionState";
import { useGameState } from "@/hooks/useGameState";
import { useGameTimer } from "@/hooks/useGameTimer";
import { useVisualFeedback } from "@/hooks/useVisualFeedback";
import { useWordDetection } from "@/hooks/useWordDetection";
import { RTVIEvent } from "@pipecat-ai/client-js";
import { useRTVIClientEvent } from "@pipecat-ai/client-react";
import { IconCircleDashedCheck, IconDoorExit } from "@tabler/icons-react";
import { useCallback, useEffect, useRef } from "react";
import Logo from "../../assets/logo.png";
import { GameContent } from "./GameContent";
import { ScoreRow } from "./ScoreRow";
import JSConfetti from "js-confetti";
import Image from "next/image";
import styles from "./WordWrangler.module.css";
export const WordWrangler: React.FC<{
onGameEnded: (score: number, bestScore: number) => void;
}> = ({ onGameEnded }) => {
const botIntroCompletedRef = useRef(false);
const currentScoreRef = useRef(0);
const gameState = useGameState();
const visualFeedback = useVisualFeedback();
const { isConnected, client } = useConnectionState();
// Update the ref whenever score changes
useEffect(() => {
currentScoreRef.current = gameState.score;
}, [gameState.score]);
// End the game
const endGame = useCallback(async () => {
const scoreAtCallTime = currentScoreRef.current;
// Prevent multiple calls to endGame
if (gameState.gameState === GAME_STATES.FINISHED) {
console.log("endGame prevented - game already finished");
return;
}
// Capture the current score before any state changes
const finalScore = scoreAtCallTime;
const currentBestScore = gameState.bestScore;
// Update game state
gameState.finishGame();
visualFeedback.resetVisuals();
// Update best score if needed
if (currentBestScore < finalScore) {
gameState.setBestScore(finalScore);
}
// Disconnect the bot
if (client && isConnected) {
try {
await client.disconnectBot();
await client.disconnect();
} catch (error) {
console.error("Error disconnecting bot:", error);
}
}
// Call the callback with the captured scores
onGameEnded(finalScore, Math.max(finalScore, currentBestScore));
}, [gameState, visualFeedback, client, isConnected, onGameEnded]);
const gameTimer = useGameTimer(endGame);
const wordDetection = useWordDetection({
gameState: gameState.gameState,
currentWord: gameState.currentWord,
onCorrectGuess: handleCorrectGuess,
onIncorrectGuess: handleIncorrectGuess,
});
// Initialize on component mount
useEffect(() => {
gameState.initializeGame();
}, []);
// Handle connection state changes
useEffect(() => {
if (isConnected) {
if (!botIntroCompletedRef.current) {
// Connection is active, but bot hasn't completed intro
gameState.setGameState(GAME_STATES.WAITING_FOR_INTRO);
}
} else {
// Connection lost or never established
if (gameState.gameState === GAME_STATES.ACTIVE) {
// If game was active, it's now finished
endGame();
} else if (gameState.gameState !== GAME_STATES.FINISHED) {
// Reset to idle state if not already finished
gameState.setGameState(GAME_STATES.IDLE);
}
// Reset intro state when connection is lost
botIntroCompletedRef.current = false;
}
}, [isConnected, gameState.gameState, endGame]);
// Listen for the bot to stop speaking to detect intro completion
useRTVIClientEvent(RTVIEvent.BotStoppedSpeaking, () => {
if (
gameState.gameState === GAME_STATES.WAITING_FOR_INTRO &&
!botIntroCompletedRef.current
) {
// First time the bot stops speaking, consider intro done and start the game
botIntroCompletedRef.current = true;
startGame();
}
});
// Handle correct guess with animation
function handleCorrectGuess() {
visualFeedback.showCorrect(() => {
gameState.incrementScore();
gameState.moveToNextWord();
wordDetection.resetLastProcessedMessage();
});
const jsConfetti = new JSConfetti();
jsConfetti.addConfetti();
}
// Handle incorrect guess with animation
function handleIncorrectGuess() {
visualFeedback.showIncorrectAnimation();
}
// Start the game
function startGame() {
// Initialize game state
gameState.initializeGame();
wordDetection.resetLastProcessedMessage();
// Start the timer - now it internally manages countdown and calls endGame when done
gameTimer.startTimer();
}
// Handle manual marking as correct
function handleManualCorrect() {
if (gameState.gameState !== GAME_STATES.ACTIVE) return;
gameState.incrementScore();
const jsConfetti = new JSConfetti();
jsConfetti.addConfetti();
gameState.moveToNextWord();
wordDetection.resetLastProcessedMessage();
}
// Handle skipping a word
function handleSkip() {
if (gameState.gameState !== GAME_STATES.ACTIVE) return;
// Try to use a skip and proceed if successful
if (gameState.useSkip()) {
gameState.moveToNextWord();
wordDetection.resetLastProcessedMessage();
}
}
// Clean up on unmount
useEffect(() => {
return () => {
gameTimer.stopTimer();
visualFeedback.cleanup();
};
}, []);
return (
<div className="min-h-[100dvh] flex flex-col">
<div className="flex-1 flex flex-col items-center justify-center h-screen">
<div className="flex flex-1 flex-col lg:flex-row gap-6 lg:gap-12 items-center justify-center w-full lg:w-auto">
<div className={styles.gameContainer}>
<Image
src={Logo}
alt="Word Wrangler"
className="logo size-[140px] absolute top-[-50px] lg:top-[-60px] left-[50%] -translate-x-1/2 lg:left-auto lg:-translate-x-0 lg:right-[-50px] z-10"
priority
/>
<div className={styles.gameContent}>
<GameContent
gameState={gameState.gameState}
timeLeft={gameTimer.timeLeft}
currentWord={gameState.currentWord}
showAutoDetected={visualFeedback.showAutoDetected}
showIncorrect={visualFeedback.showIncorrect}
score={gameState.score}
skipsRemaining={gameState.skipsRemaining}
onSkip={handleSkip}
/>
</div>
</div>
<ScoreRow score={gameState.score} bestScore={gameState.bestScore} />
</div>
<footer className="flex gap-2 py-4 lg:flex-row lg:gap-4 lg:py-6 w-full items-center justify-center">
<button
className="button outline w-full lg:w-auto"
onClick={handleManualCorrect}
disabled={gameState.gameState !== GAME_STATES.ACTIVE}
>
<IconCircleDashedCheck size={24} />
{GAME_TEXT.correct}
</button>
<button
className="button outline w-full lg:w-auto"
onClick={endGame}
disabled={
gameState.gameState == GAME_STATES.CONNECTING ||
gameState.gameState == GAME_STATES.WAITING_FOR_INTRO
}
>
<IconDoorExit size={24} />
End Game
</button>
</footer>
</div>
</div>
);
};

View File

@@ -0,0 +1,45 @@
import { BUTTON_TEXT } from "@/constants/gameConstants";
import { useConnectionState } from "@/hooks/useConnectionState";
import { IconArrowRight } from "@tabler/icons-react";
interface StartGameButtonProps {
onGameStarted?: () => void;
onGameEnded?: () => void;
isGameEnded?: boolean;
}
export function StartGameButton({
onGameStarted,
onGameEnded,
isGameEnded,
}: StartGameButtonProps) {
const { isConnecting, isDisconnecting, toggleConnection } =
useConnectionState(onGameStarted, onGameEnded);
// Show spinner during connection process
const showSpinner = isConnecting;
const btnText = isGameEnded ? BUTTON_TEXT.RESTART : BUTTON_TEXT.START;
return (
<div className="flex justify-center">
<button
className="styled-button"
onClick={toggleConnection}
disabled={isConnecting || isDisconnecting}
>
<>
<span className="styled-button-text">
{isConnecting ? BUTTON_TEXT.CONNECTING : btnText}
</span>
<span className="styled-button-icon">
{showSpinner ? (
<span className="spinner"></span>
) : (
<IconArrowRight size={16} strokeWidth={3} />
)}
</span>
</>
</button>
</div>
);
}

View File

@@ -0,0 +1,65 @@
// Game configuration
export const GAME_CONFIG = {
MAX_SKIPS: 3,
GAME_DURATION: 60, // seconds
WORD_POOL_SIZE: 30,
ANIMATION_DURATION: 1000, // ms
TIMER_INTERVAL: 1000, // ms
LOW_TIME_WARNING: 10, // seconds
};
// Game states
export const GAME_STATES = {
IDLE: "idle",
CONNECTING: "connecting",
WAITING_FOR_INTRO: "waitingForIntro",
ACTIVE: "active",
FINISHED: "finished",
} as const;
export type GameState = (typeof GAME_STATES)[keyof typeof GAME_STATES];
// Text used in the game
export const GAME_TEXT = {
time: "Time",
score: "Score",
gameOver: "Game Over!",
finalScore: "Final Score",
correct: "Mark Correct",
skip: "Skip →",
noSkips: "No Skips Left",
skipsRemaining: (num: number) => `Skip (${num} left)`,
startingGame: `How many words can you describe in ${GAME_CONFIG.GAME_DURATION} seconds?`,
waitingForIntro: "Getting ready...",
clickToStart: "Press Start Game to begin",
describeWord: "Describe the following word:",
introTitle: "How many words can you describe within 60 seconds?",
introGuide1: "Earn points each time the AI correctly guesses the word",
introGuide2: "Do not say the word, or you will lose points",
introGuide3: "You can skip the word if you don't know it",
aiPersonality: "AI Personality",
finalScoreMessage: "Your best score:",
};
// Pattern for detecting guesses in transcripts
export const TRANSCRIPT_PATTERNS = {
// Match both "Is it "word"?" and "Is it a/an word?" patterns
GUESS_PATTERN:
/is it [""]?([^""?]+)[""]?(?:\?)?|is it (?:a|an) ([^?]+)(?:\?)?/i,
};
// Connection states
export const CONNECTION_STATES = {
ACTIVE: ["connected", "ready"],
CONNECTING: ["connecting", "initializing", "initialized", "authenticating"],
DISCONNECTING: ["disconnecting"],
};
// Button text
export const BUTTON_TEXT = {
START: "Start Game",
END: "End Game",
CONNECTING: "Connecting...",
STARTING: "Starting...",
RESTART: "Play Again",
};

View File

@@ -0,0 +1,43 @@
import React, { createContext, useContext, useState, ReactNode } from 'react';
import { PersonalityType, DEFAULT_PERSONALITY } from '@/types/personality';
interface ConfigurationContextProps {
personality: PersonalityType;
setPersonality: (personality: PersonalityType) => void;
}
const ConfigurationContext = createContext<
ConfigurationContextProps | undefined
>(undefined);
interface ConfigurationProviderProps {
children: ReactNode;
}
export function ConfigurationProvider({
children,
}: ConfigurationProviderProps) {
const [personality, setPersonality] =
useState<PersonalityType>(DEFAULT_PERSONALITY);
const value = {
personality,
setPersonality,
};
return (
<ConfigurationContext.Provider value={value}>
{children}
</ConfigurationContext.Provider>
);
}
export function useConfigurationSettings() {
const context = useContext(ConfigurationContext);
if (context === undefined) {
throw new Error(
'useConfigurationSettings must be used within a ConfigurationProvider'
);
}
return context;
}

View File

@@ -0,0 +1,535 @@
// 100 Easy Words - Common, everyday objects and concepts
export const EASY_CATCH_PHRASE_WORDS = [
// Common Objects
'Chair',
'Table',
'Door',
'Window',
'Book',
'Pencil',
'Phone',
'Computer',
'Ball',
'Car',
'Shoe',
'Hat',
'Cup',
'Plate',
'Fork',
'Spoon',
'Knife',
'Key',
'Clock',
'Watch',
// Food & Drink
'Pizza',
'Hamburger',
'Ice cream',
'Chocolate',
'Apple',
'Banana',
'Orange',
'Milk',
'Water',
'Cake',
'Cookie',
'Bread',
'Egg',
'Cheese',
'Chicken',
// Animals
'Dog',
'Cat',
'Fish',
'Bird',
'Horse',
'Cow',
'Pig',
'Duck',
'Lion',
'Tiger',
'Bear',
'Elephant',
'Monkey',
'Rabbit',
'Frog',
// Colors & Simple Concepts
'Red',
'Blue',
'Green',
'Yellow',
'Black',
'White',
'Big',
'Small',
'Hot',
'Cold',
'Happy',
'Sad',
'Fast',
'Slow',
'Old',
'Young',
'Up',
'Down',
'Left',
'Right',
// Simple Activities
'Run',
'Walk',
'Jump',
'Swim',
'Sleep',
'Eat',
'Drink',
'Laugh',
'Cry',
'Smile',
'Play',
'Work',
'Read',
'Write',
'Draw',
'Sing',
'Dance',
'Talk',
'Listen',
'Cook',
];
// 300 Medium Words - More specific items, common concepts, popular culture
export const MEDIUM_CATCH_PHRASE_WORDS = [
// Household Items & Technology
'Refrigerator',
'Television',
'Microwave',
'Bookshelf',
'Couch',
'Dishwasher',
'Ceiling fan',
'Toaster',
'Vacuum cleaner',
'Blender',
'Printer',
'Headphones',
'Smartphone',
'Laptop',
'Tablet',
'Camera',
'Remote control',
'Charger',
'Keyboard',
'Mouse',
'Lightbulb',
'Shower curtain',
'Doorknob',
'Power outlet',
'Coffee maker',
// Food & Cuisine
'Spaghetti',
'Burrito',
'Sushi',
'Pancake',
'Waffle',
'Cereal',
'Sandwich',
'Salad',
'French fries',
'Hot dog',
'Cupcake',
'Donut',
'Milkshake',
'Smoothie',
'Oatmeal',
'Peanut butter',
'Jelly',
'Bacon',
'Scrambled eggs',
'Toast',
'Steak',
'Mashed potatoes',
'Broccoli',
'Carrot',
'Onion',
// Animals & Nature
'Giraffe',
'Penguin',
'Kangaroo',
'Dolphin',
'Octopus',
'Butterfly',
'Spider',
'Eagle',
'Turtle',
'Squirrel',
'Rainbow',
'Waterfall',
'Mountain',
'Beach',
'Forest',
'Hurricane',
'Snowflake',
'Thunderstorm',
'Volcano',
'Desert',
'Sunrise',
'Sunset',
'Moon',
'Stars',
'Planet',
// Sports & Activities
'Basketball',
'Football',
'Soccer',
'Baseball',
'Tennis',
'Golf',
'Swimming',
'Skiing',
'Snowboarding',
'Hiking',
'Camping',
'Fishing',
'Gardening',
'Painting',
'Photography',
'Cycling',
'Jogging',
'Yoga',
'Dancing',
'Cooking',
'Driving',
'Flying',
'Sailing',
'Surfing',
'Rock climbing',
// Clothing & Accessories
'Sunglasses',
'Umbrella',
'Necklace',
'Bracelet',
'Ring',
'Earrings',
'Backpack',
'Purse',
'Wallet',
'Watch',
'Sneakers',
'Sandals',
'Boots',
'High heels',
'Flip flops',
'Scarf',
'Gloves',
'Belt',
'Tie',
'Jacket',
'Sweater',
'Sweatshirt',
'Jeans',
'Shorts',
'Dress',
// Places
'Restaurant',
'Grocery store',
'Shopping mall',
'Movie theater',
'Park',
'School',
'Library',
'Museum',
'Zoo',
'Airport',
'Hospital',
'Hotel',
'Bank',
'Post office',
'Gym',
'Beach',
'Swimming pool',
'Church',
'Stadium',
'Concert hall',
'Farm',
'City',
'Village',
'Country',
'Island',
// Jobs & Professions
'Teacher',
'Doctor',
'Nurse',
'Police officer',
'Firefighter',
'Chef',
'Waiter',
'Pilot',
'Engineer',
'Scientist',
'Actor',
'Singer',
'Artist',
'Writer',
'Photographer',
'Farmer',
'Mechanic',
'Electrician',
'Plumber',
'Carpenter',
'Lawyer',
'Accountant',
'Businessman',
'Salesperson',
'Architect',
// Transportation
'Bicycle',
'Motorcycle',
'Bus',
'Train',
'Airplane',
'Helicopter',
'Boat',
'Ship',
'Submarine',
'Rocket',
'Taxi',
'Ambulance',
'Fire truck',
'Police car',
'School bus',
'Skateboard',
'Scooter',
'Rollerblades',
'Wagon',
'Sled',
'Escalator',
'Elevator',
'Tractor',
'Bulldozer',
'Crane',
// Entertainment & Hobbies
'Movie',
'Music',
'Book',
'Game',
'Puzzle',
'Toy',
'Doll',
'Action figure',
'Video game',
'Board game',
'Knitting',
'Sewing',
'Woodworking',
'Baking',
'Grilling',
'Hunting',
'Archery',
'Bowling',
'Karaoke',
'Dancing',
'Collecting',
'Reading',
'Writing',
'Drawing',
'Painting',
// Body Parts & Health
'Heart',
'Brain',
'Stomach',
'Lungs',
'Liver',
'Kidneys',
'Skin',
'Hair',
'Nails',
'Teeth',
'Eyes',
'Ears',
'Nose',
'Mouth',
'Throat',
'Shoulder',
'Elbow',
'Wrist',
'Hip',
'Knee',
'Ankle',
'Exercise',
'Medicine',
'Doctor',
'Hospital',
// Common Concepts
'Birthday',
'Wedding',
'Funeral',
'Holiday',
'Vacation',
'Friendship',
'Love',
'Family',
'Education',
'Career',
'Money',
'Time',
'Weather',
'Season',
'History',
'Future',
'Success',
'Failure',
'Challenge',
'Opportunity',
'Competition',
'Cooperation',
'Leadership',
'Creativity',
'Innovation',
];
// 100 Hard Words - Abstract concepts, specific terminology, complex ideas
export const HARD_CATCH_PHRASE_WORDS = [
// Academic & Scientific Terms
'Photosynthesis',
'Mitochondria',
'Quantum physics',
'Relativity',
'Biodiversity',
'Logarithm',
'Algorithm',
'Neural network',
'Nanotechnology',
'Thermodynamics',
'Paleontology',
'Archaeology',
'Linguistics',
'Metaphysics',
'Epistemology',
'Cryptocurrency',
'Blockchain',
'Artificial intelligence',
'Machine learning',
'Virtual reality',
// Abstract Concepts
'Nostalgia',
'Ambivalence',
'Empathy',
'Serendipity',
'Irony',
'Existentialism',
'Utilitarianism',
'Nihilism',
'Altruism',
'Pragmatism',
'Transcendence',
'Symbolism',
'Paradox',
'Dichotomy',
'Synchronicity',
'Meritocracy',
'Bureaucracy',
'Democracy',
'Capitalism',
'Socialism',
// Specialized Terminology
'Amortization',
'Cryptocurrency',
'Jurisprudence',
'Cartography',
'Meteorology',
'Gentrification',
'Immunology',
'Horticulture',
'Gerontology',
'Acoustics',
'Encryption',
'Astrophysics',
'Ornithology',
'Entomology',
'Viticulture',
'Numismatics',
'Philately',
'Calligraphy',
'Lexicography',
'Etymology',
// Cultural & Historical References
'Renaissance',
'Industrial Revolution',
'Cold War',
'Enlightenment',
'Reformation',
'Colonialism',
'Globalization',
'Diaspora',
'Apartheid',
'Imperialism',
'Monarchy',
'Republic',
'Federation',
'Aristocracy',
'Oligarchy',
'Surrealism',
'Impressionism',
'Baroque',
'Neoclassicism',
'Romanticism',
// Complex Activities & Processes
'Meditation',
'Negotiation',
'Diplomacy',
'Arbitration',
'Litigation',
'Legislation',
'Deliberation',
'Investigation',
'Implementation',
'Extrapolation',
'Procurement',
'Outsourcing',
'Diversification',
'Consolidation',
'Optimization',
'Orchestration',
'Choreography',
'Composition',
'Improvisation',
'Interpretation',
];
// Combined lists for random selection
export const ALL_CATCH_PHRASE_WORDS = [
...EASY_CATCH_PHRASE_WORDS,
...MEDIUM_CATCH_PHRASE_WORDS,
...HARD_CATCH_PHRASE_WORDS,
];
// Get a batch of random words (useful for starting a game with multiple words)
export const getRandomCatchPhraseWords = (count: number = 30): string[] => {
const wordList = [...ALL_CATCH_PHRASE_WORDS];
// Shuffle the array using Fisher-Yates algorithm
for (let i = wordList.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[wordList[i], wordList[j]] = [wordList[j], wordList[i]];
}
return wordList.slice(0, count);
};

View File

@@ -0,0 +1,53 @@
import { useEffect, useCallback } from 'react';
import {
useRTVIClient,
useRTVIClientTransportState,
} from '@pipecat-ai/client-react';
import { CONNECTION_STATES } from '@/constants/gameConstants';
export function useConnectionState(
onConnected?: () => void,
onDisconnected?: () => void
) {
const client = useRTVIClient();
const transportState = useRTVIClientTransportState();
const isConnected = CONNECTION_STATES.ACTIVE.includes(transportState);
const isConnecting = CONNECTION_STATES.CONNECTING.includes(transportState);
const isDisconnecting =
CONNECTION_STATES.DISCONNECTING.includes(transportState);
// Handle connection changes
useEffect(() => {
if (isConnected && onConnected) {
onConnected();
}
if (!isConnected && !isConnecting && onDisconnected) {
onDisconnected();
}
}, [isConnected, isConnecting, onConnected, onDisconnected]);
// Toggle connection state
const toggleConnection = useCallback(async () => {
if (!client) return;
try {
if (isConnected) {
await client.disconnect();
} else {
await client.connect();
}
} catch (error) {
console.error('Connection error:', error);
}
}, [client, isConnected]);
return {
isConnected,
isConnecting,
isDisconnecting,
toggleConnection,
transportState,
client, // Expose the client for direct access when needed
};
}

View File

@@ -0,0 +1,98 @@
import { GAME_CONFIG, GAME_STATES, GameState } from "@/constants/gameConstants";
import { getRandomCatchPhraseWords } from "@/data/wordWranglerWords";
import { useCallback, useState } from "react";
export function useGameState() {
// Game state
const [gameState, setGameState] = useState<GameState>(GAME_STATES.IDLE);
const [timeLeft, setTimeLeft] = useState(GAME_CONFIG.GAME_DURATION);
const [score, setScore] = useState(0);
const [words, setWords] = useState<string[]>([]);
const [currentWordIndex, setCurrentWordIndex] = useState(0);
const [skipsRemaining, setSkipsRemaining] = useState(GAME_CONFIG.MAX_SKIPS);
const [bestScore, _setBestScore] = useState(0);
// Initialize or reset game state
const initializeGame = useCallback(() => {
const freshWords = getRandomCatchPhraseWords(GAME_CONFIG.WORD_POOL_SIZE);
setWords(freshWords);
setGameState(GAME_STATES.ACTIVE);
setTimeLeft(GAME_CONFIG.GAME_DURATION);
setScore(0);
setCurrentWordIndex(0);
setSkipsRemaining(GAME_CONFIG.MAX_SKIPS);
// Get best score from local storage
const storedScore = localStorage.getItem("bestScore");
if (storedScore) {
_setBestScore(Number(storedScore) || 0);
}
return freshWords;
}, []);
// End game
const finishGame = useCallback(() => {
setGameState(GAME_STATES.FINISHED);
}, []);
// Handle scoring
const incrementScore = useCallback(() => {
setScore((prev) => prev + 1);
}, []);
// Handle best score
const setBestScore = useCallback((newBestScore: number) => {
_setBestScore(newBestScore);
localStorage.setItem("bestScore", newBestScore.toString());
}, []);
// Handle word navigation
const moveToNextWord = useCallback(() => {
setCurrentWordIndex((prev) => {
if (prev >= words.length - 1) {
// If we're at the end of the word list, get new words
setWords(getRandomCatchPhraseWords(GAME_CONFIG.WORD_POOL_SIZE));
return 0;
}
return prev + 1;
});
}, [words]);
// Handle skipping
const useSkip = useCallback(() => {
if (skipsRemaining <= 0) return false;
setSkipsRemaining((prev) => prev - 1);
return true;
}, [skipsRemaining]);
// Update timer
const decrementTimer = useCallback(() => {
return setTimeLeft((prev) => {
if (prev <= 1) {
return 0;
}
return prev - 1;
});
}, []);
return {
// State
gameState,
setGameState,
timeLeft,
score,
bestScore,
words,
currentWord: words[currentWordIndex] || "",
skipsRemaining,
// Actions
initializeGame,
finishGame,
incrementScore,
setBestScore,
moveToNextWord,
useSkip,
decrementTimer,
};
}

View File

@@ -0,0 +1,59 @@
import { GAME_CONFIG } from "@/constants/gameConstants";
import { clearTimer } from "@/utils/timerUtils";
import { useCallback, useEffect, useRef, useState } from "react";
export function useGameTimer(onTimeUp: () => void) {
const [timeLeft, setTimeLeft] = useState(GAME_CONFIG.GAME_DURATION);
const timerRef = useRef<NodeJS.Timeout | null>(null);
const hasCalledTimeUpRef = useRef(false);
// Start the game timer with initial duration
const startTimer = useCallback(() => {
// Reset time left and timeUp flag
setTimeLeft(GAME_CONFIG.GAME_DURATION);
hasCalledTimeUpRef.current = false;
// Clear any existing timer
timerRef.current = clearTimer(timerRef.current);
// Start a new timer
timerRef.current = setInterval(() => {
setTimeLeft((prev) => {
if (prev <= 1 && !hasCalledTimeUpRef.current) {
// Time's up - clear the interval and call the callback
timerRef.current = clearTimer(timerRef.current);
hasCalledTimeUpRef.current = true;
onTimeUp();
return 0;
}
return prev - 1;
});
}, GAME_CONFIG.TIMER_INTERVAL);
}, [onTimeUp]);
// Stop the timer
const stopTimer = useCallback(() => {
timerRef.current = clearTimer(timerRef.current);
hasCalledTimeUpRef.current = false;
}, []);
// Reset the timer to initial value without starting it
const resetTimer = useCallback(() => {
setTimeLeft(GAME_CONFIG.GAME_DURATION);
hasCalledTimeUpRef.current = false;
}, []);
// Cleanup on unmount
useEffect(() => {
return () => {
timerRef.current = clearTimer(timerRef.current);
};
}, []);
return {
timeLeft,
startTimer,
stopTimer,
resetTimer,
};
}

View File

@@ -0,0 +1,62 @@
import { useState, useRef, useCallback } from 'react';
import { clearTimer } from '@/utils/timerUtils';
import { GAME_CONFIG } from '@/constants/gameConstants';
export function useVisualFeedback() {
// Visual feedback state
const [showAutoDetected, setShowAutoDetected] = useState(false);
const [showIncorrect, setShowIncorrect] = useState(false);
const autoDetectTimerRef = useRef<NodeJS.Timeout | null>(null);
// Reset all visual states
const resetVisuals = useCallback(() => {
setShowAutoDetected(false);
setShowIncorrect(false);
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
}, []);
// Show correct animation
const showCorrect = useCallback((onComplete?: () => void) => {
// Clear any existing animation
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
// Show correct animation
setShowAutoDetected(true);
setShowIncorrect(false);
// Set timeout to hide animation
autoDetectTimerRef.current = setTimeout(() => {
setShowAutoDetected(false);
if (onComplete) onComplete();
}, GAME_CONFIG.ANIMATION_DURATION);
}, []);
// Show incorrect animation
const showIncorrectAnimation = useCallback(() => {
// Clear any existing animation
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
// Show incorrect animation
setShowIncorrect(true);
setShowAutoDetected(false);
// Set timeout to hide animation
autoDetectTimerRef.current = setTimeout(() => {
setShowIncorrect(false);
}, GAME_CONFIG.ANIMATION_DURATION);
}, []);
// Clean up function
const cleanup = useCallback(() => {
autoDetectTimerRef.current = clearTimer(autoDetectTimerRef.current);
}, []);
return {
showAutoDetected,
showIncorrect,
resetVisuals,
showCorrect,
showIncorrectAnimation,
cleanup,
};
}

View File

@@ -0,0 +1,62 @@
import { useRef } from 'react';
import { useRTVIClientEvent } from '@pipecat-ai/client-react';
import { RTVIEvent } from '@pipecat-ai/client-js';
import { detectWordGuess } from '@/utils/wordDetection';
import { GAME_STATES, GameState } from '@/constants/gameConstants';
interface UseWordDetectionProps {
gameState: GameState;
currentWord: string;
onCorrectGuess: () => void;
onIncorrectGuess: () => void;
}
export function useWordDetection({
gameState,
currentWord,
onCorrectGuess,
onIncorrectGuess,
}: UseWordDetectionProps) {
const lastProcessedMessageRef = useRef('');
// Reset the last processed message
const resetLastProcessedMessage = () => {
lastProcessedMessageRef.current = '';
};
// Listen for bot transcripts to detect correct answers
useRTVIClientEvent(RTVIEvent.BotTranscript, (data) => {
if (gameState !== GAME_STATES.ACTIVE) {
return;
}
if (!currentWord) {
return;
}
if (!data.text) {
return;
}
// Skip if this is a repeat of the same transcript
if (data.text === lastProcessedMessageRef.current) {
return;
}
lastProcessedMessageRef.current = data.text;
// Use the utility function to detect word guesses
const result = detectWordGuess(data.text, currentWord);
if (result.isCorrect) {
onCorrectGuess();
} else if (result.isExplicitGuess) {
onIncorrectGuess();
} else {
}
});
return {
resetLastProcessedMessage,
};
}

View File

@@ -0,0 +1,32 @@
import { ConfigurationProvider } from "@/contexts/Configuration";
import { RTVIProvider } from "@/providers/RTVIProvider";
import { RTVIClientAudio } from "@pipecat-ai/client-react";
import type { AppProps } from "next/app";
import { Nunito } from "next/font/google";
import Head from "next/head";
import "../styles/globals.css";
const nunito = Nunito({
subsets: ["latin"],
display: "swap",
variable: "--font-sans",
});
export default function App({ Component, pageProps }: AppProps) {
return (
<>
<Head>
<title>Daily | Word Wrangler</title>
<meta name="viewport" content="width=device-width, initial-scale=1" />
</Head>
<main className={`${nunito.variable}`}>
<ConfigurationProvider>
<RTVIProvider>
<RTVIClientAudio />
<Component {...pageProps} />
</RTVIProvider>
</ConfigurationProvider>
</main>
</>
);
}

View File

@@ -0,0 +1,47 @@
import { Head, Html, Main, NextScript } from "next/document";
export default function Document() {
return (
<Html lang="en">
<Head>
<meta
name="description"
content="Describe words without saying them and an AI will guess them!"
/>
<link rel="icon" href="/favicon.ico" />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<meta charSet="UTF-8" />
{/* Open Graph / Social Media Meta Tags */}
<meta property="og:type" content="website" />
<meta property="og:url" content="https://word-wrangler.vercel.app/" />
<meta
property="og:title"
content="Word Wrangler - AI Word Guessing Game"
/>
<meta
property="og:description"
content="Describe words without saying them and an AI will guess them!"
/>
<meta property="og:image" content="/og-image.png" />
{/* Twitter Card Meta Tags */}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:url" content="https://word-wrangler.vercel.app/" />
<meta
name="twitter:title"
content="Word Wrangler - AI Word Guessing Game"
/>
<meta
name="twitter:description"
content="Describe words without saying them and an AI will guess them!"
/>
<meta name="twitter:image" content="/og-image.png" />
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}

View File

@@ -0,0 +1,51 @@
import type { NextApiRequest, NextApiResponse } from "next";
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
if (req.method !== "POST") {
return res.status(405).json({ error: "Method not allowed" });
}
try {
const { personality } = req.body;
// Validate required parameters
if (!personality) {
return res
.status(400)
.json({ error: "Missing required configuration parameters" });
}
const response = await fetch(
`https://api.pipecat.daily.co/v1/public/${process.env.AGENT_NAME}/start`,
{
method: "POST",
headers: {
Authorization: `Bearer ${process.env.PIPECAT_CLOUD_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
createDailyRoom: true,
body: {
personality,
},
}),
}
);
const data = await response.json();
console.log("Response from API:", JSON.stringify(data, null, 2));
// Transform the response to match what RTVI client expects
return res.status(200).json({
room_url: data.dailyRoom,
token: data.dailyToken,
});
} catch (error) {
console.error("Error starting agent:", error);
return res.status(500).json({ error: "Failed to start agent" });
}
}

View File

@@ -0,0 +1,168 @@
import { Card, CardInner } from "@/components/Card";
import { WordWrangler } from "@/components/Game/WordWrangler";
import { StartGameButton } from "@/components/StartButton";
import { GAME_TEXT } from "@/constants/gameConstants";
import { useConfigurationSettings } from "@/contexts/Configuration";
import { PERSONALITY_PRESETS, PersonalityType } from "@/types/personality";
import {
IconArrowForwardUp,
IconCheck,
IconCode,
IconX,
} from "@tabler/icons-react";
import JSConfetti from "js-confetti";
import Image from "next/image";
import Link from "next/link";
import { useEffect, useState } from "react";
import Logo from "../assets/logo.png";
import Star from "../assets/star.png";
export default function Home() {
const [hasStarted, setHasStarted] = useState(false);
const [gameEnded, setGameEnded] = useState(false);
const [score, setScore] = useState(0);
const [bestScore, setBestScore] = useState(0);
const config = useConfigurationSettings();
useEffect(() => {
if (gameEnded) {
const confetti = new JSConfetti();
confetti.addConfetti({
emojis: ["⭐", "⚡️", "👑", "✨", "💫", "🏆", "💯"],
});
}
}, [gameEnded]);
if (gameEnded) {
return (
<div className="flex flex-col justify-between lg:justify-center items-center min-h-[100dvh] py-4">
<div className="flex flex-1 w-full">
<Card className="w-full lg:max-w-2xl mx-auto mt-[50px] lg:mt-[120px] self-center text-center pt-[62px]">
<div className="flex items-center justify-center w-[162px] h-[162px] rounded-full absolute z-20 -top-[81px] left-1/2 -translate-x-1/2 animate-bounce-in">
<Image src={Star} alt="Star" priority />
</div>
<CardInner>
<h2 className="text-xl font-extrabold">{GAME_TEXT.finalScore}</h2>
<p className="text-4xl font-extrabold text-emerald-700 bg-emerald-50 rounded-full px-4 py-4 my-4">
{score}
</p>
<p className="font-medium text-slate-500">
{GAME_TEXT.finalScoreMessage}{" "}
<span className="text-slate-700 font-extrabold">
{bestScore}
</span>
</p>
<div className="h-[1px] bg-slate-200 my-6" />
<div className="flex items-center justify-center">
<Link
href="https://github.com/daily-co/word-wrangler-gemini-live"
className="button ghost w-full lg:w-auto"
target="_blank"
rel="noopener noreferrer"
>
<IconCode size={24} />
View project source code
</Link>
</div>
</CardInner>
</Card>
</div>
<footer className="flex flex-col justify-center w-full py-4 lg:py-12">
<StartGameButton
isGameEnded={true}
onGameStarted={() => {
setGameEnded(false);
setScore(0);
setHasStarted(true);
}}
/>
</footer>
</div>
);
}
if (!hasStarted) {
return (
<div className="flex flex-col justify-between items-center min-h-[100dvh] py-4 overflow-hidden">
<div className="flex flex-1">
<Card className="lg:min-w-2xl mx-auto mt-[50px] lg:mt-[120px] self-center">
<Image
src={Logo}
alt="Word Wrangler"
className="logo size-[150px] lg:size-[278px] absolute top-[-75px] lg:top-[-139px] left-[50%] -translate-x-1/2 z-10 animate-bounce-in"
priority
/>
<CardInner>
<div className="flex flex-col gap-5 lg:gap-8 text-center mt-[50px] lg:mt-[100px]">
<h2 className="text-xl font-extrabold">
{GAME_TEXT.introTitle}
</h2>
<div className="flex flex-col gap-3 lg:gap-4">
<div className="flex flex-row gap-3 relative">
<div className="absolute -top-3 -left-3 border-3 border-white lg:static size-10 lg:size-12 bg-emerald-100 text-emerald-500 rounded-full flex items-center justify-center font-semibold">
<IconCheck size={24} />
</div>
<div className="flex-1 flex h-[53px] lg:h-auto bg-slate-100 rounded-full text-slate-500 leading-5 px-12 items-center justify-center font-semibold text-pretty text-sm lg:text-base">
{GAME_TEXT.introGuide1}
</div>
</div>
<div className="flex flex-row gap-3 relative">
<div className="absolute -top-3 -left-3 border-3 border-white lg:static size-10 lg:size-12 bg-red-100 text-red-500 rounded-full flex items-center justify-center font-semibold">
<IconX size={24} />
</div>
<div className="flex-1 flex h-[53px] lg:h-auto bg-slate-100 rounded-full text-slate-500 leading-5 px-12 items-center justify-center font-semibold text-pretty text-sm lg:text-base">
{GAME_TEXT.introGuide2}
</div>
</div>
<div className="flex flex-row gap-3 relative">
<div className="absolute -top-3 -left-3 border-3 border-white lg:static size-10 lg:size-12 bg-slate-100 text-slate-400 rounded-full flex items-center justify-center font-semibold">
<IconArrowForwardUp size={24} />
</div>
<div className="flex-1 flex h-[53px] lg:h-auto bg-slate-100 rounded-full text-slate-500 leading-5 px-12 items-center justify-center font-semibold text-pretty text-sm lg:text-base">
{GAME_TEXT.introGuide3}
</div>
</div>
</div>
</div>
<div className="flex-1 bg-slate-100 h-[1px] my-4 lg:my-6" />
<div>
<label className="font-bold flex flex-col gap-2 flex-1">
{GAME_TEXT.aiPersonality}
<select
className="rounded-xl h-11 font-normal"
value={config.personality}
onChange={(e) =>
config.setPersonality(e.target.value as PersonalityType)
}
>
{Object.entries(PERSONALITY_PRESETS).map(
([value, label]) => (
<option key={value} value={value}>
{label}
</option>
)
)}
</select>
</label>
</div>
</CardInner>
</Card>
</div>
<footer className="flex flex-col justify-center w-full py-4 lg:py-12">
<StartGameButton onGameStarted={() => setHasStarted(true)} />
</footer>
</div>
);
}
return (
<WordWrangler
onGameEnded={(score, bestScore = 0) => {
setScore(score);
setBestScore(bestScore);
setGameEnded(true);
}}
/>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { RTVIClient } from "@pipecat-ai/client-js";
import { DailyTransport } from "@pipecat-ai/daily-transport";
import { RTVIClientProvider } from "@pipecat-ai/client-react";
import { PropsWithChildren, useEffect, useState, useRef } from "react";
import { useConfigurationSettings } from "@/contexts/Configuration";
// Get the API base URL from environment variables
// Default to "/api" if not specified
// "/api" is the default for Next.js API routes and used
// for the Pipecat Cloud deployed agent
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || "/api";
console.log("Using API base URL:", API_BASE_URL);
export function RTVIProvider({ children }: PropsWithChildren) {
const [client, setClient] = useState<RTVIClient | null>(null);
const config = useConfigurationSettings();
const clientCreated = useRef(false);
useEffect(() => {
// Only create the client once
if (clientCreated.current) return;
const transport = new DailyTransport();
const rtviClient = new RTVIClient({
transport,
params: {
baseUrl: API_BASE_URL,
endpoints: {
connect: "/connect",
},
requestData: {
personality: config.personality,
},
},
enableMic: true,
enableCam: false,
});
setClient(rtviClient);
clientCreated.current = true;
// Cleanup when component unmounts
return () => {
if (rtviClient) {
rtviClient.disconnect().catch((err) => {
console.error("Error disconnecting client:", err);
});
}
clientCreated.current = false;
};
}, []);
// Update the connectParams when config changes
useEffect(() => {
if (!client) return;
// Update the connect params without recreating the client
client.params.requestData = {
personality: config.personality,
};
}, [client, config.personality]);
if (!client) {
return null;
}
return <RTVIClientProvider client={client}>{children}</RTVIClientProvider>;
}

View File

@@ -0,0 +1,64 @@
export const styles = {
main: {
display: "flex",
flexDirection: "column" as const,
justifyContent: "flex-start",
alignItems: "center",
minHeight: "100vh",
padding: "2rem 0",
},
container: {
width: "100%",
maxWidth: "800px",
padding: "0 1rem",
},
title: {
fontSize: "2rem",
textAlign: "center" as const,
marginBottom: "2rem",
color: "#333",
},
gameContainer: {
marginBottom: "2rem",
},
controlsContainer: {
display: "flex",
flexDirection: "column" as const,
gap: "1rem",
marginBottom: "2rem",
},
settings: {
backgroundColor: "white",
padding: "1rem",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
label: {
display: "flex",
flexDirection: "column" as const,
gap: "0.5rem",
fontSize: "0.9rem",
color: "#555",
},
select: {
padding: "0.5rem",
border: "1px solid #ddd",
borderRadius: "4px",
fontSize: "1rem",
},
instructions: {
backgroundColor: "white",
padding: "1.5rem",
borderRadius: "8px",
boxShadow: "0 1px 3px rgba(0,0,0,0.1)",
},
instructionsTitle: {
fontSize: "1.4rem",
marginBottom: "1rem",
color: "#333",
},
instructionsList: {
paddingLeft: "1.5rem",
lineHeight: 1.6,
},
};

View File

@@ -0,0 +1,297 @@
@import "tailwindcss";
@theme {
--border-radius-card: 24px;
--border-width-card: 4px;
--theme-gradient-start: #fdd256;
--theme-gradient-end: #a62249;
--button-height-sm: 52px;
--button-height: 58px;
--animate-bounce-in: zoom-bounce 0.75s ease-out forwards;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
}
body {
height: 100dvh;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background: linear-gradient(180deg, #0059b7 0%, #7dceff 100%);
}
main {
font-family: var(--font-sans);
padding: 0 12px;
}
a {
color: inherit;
text-decoration: none;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
button:not(:disabled):hover {
opacity: 0.9;
}
button:not(:disabled):active {
transform: translateY(1px);
}
.card-border {
position: relative;
z-index: 1;
}
.card-border:before {
content: "";
position: absolute;
inset: -4px -4px -8px -4px;
border-radius: 28px;
background: linear-gradient(
to bottom,
rgba(0, 0, 0, 1) 0%,
rgba(0, 0, 0, 0.15) 100%
);
z-index: -1;
}
.card-border:after {
content: "";
box-sizing: border-box;
position: absolute;
inset: 0;
background: #ffffff;
border-radius: var(--border-radius-card);
border: var(--border-width-card) solid transparent;
background-image: linear-gradient(#ffffff, #ffffff),
linear-gradient(
180deg,
var(--theme-gradient-start) 0%,
var(--theme-gradient-end) 100%
);
background-origin: border-box;
background-clip: padding-box, border-box;
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding: 0 36px 0 12px;
background-image: url("data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48ZyBjbGlwLXBhdGg9InVybCgjY2xpcDBfNV80MSkiPjxwYXRoIGQ9Ik04IDlMMTIgNUwxNiA5IiBzdHJva2U9IiNCQkQ1RTEiIHN0cm9rZS13aWR0aD0iMiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PHBhdGggZD0iTTE2IDE1TDEyIDE5TDggMTUiIHN0cm9rZT0iI0JCRDU4MSIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L2c+PGRlZnM+PGNsaXBQYXRoIGlkPSJjbGlwMF81XzQxIj48cmVjdCB3aWR0aD0iMjQiIGhlaWdodD0iMjQiIGZpbGw9IndoaXRlIi8+PC9jbGlwUGF0aD48L2RlZnM+PC9zdmc+");
background-repeat: no-repeat;
background-position: right 12px center;
background-size: 24px;
border: 1px solid var(--color-slate-200);
color: var(--color-slate-600);
}
select::-ms-expand {
display: none;
}
.styled-button {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
cursor: pointer;
gap: 12px;
border-radius: 16px;
position: relative;
color: #ffffff;
text-align: center;
height: 56px;
padding: 0 32px;
background: linear-gradient(
to bottom,
transparent 0%,
transparent 88%,
rgba(255, 255, 255, 0.2) 88%,
rgba(255, 255, 255, 0.2) 100%
),
linear-gradient(180deg, #10abe3 0%, #0046b5 98.08%);
border-width: 2px 2px 5px 2px;
border-style: solid;
border-color: #000000;
overflow: hidden;
box-shadow: 0px 4px 0px 4px rgba(0, 0, 0, 0.12);
}
.styled-button:before {
content: "";
position: absolute;
inset: 0;
pointer-events: none;
border-radius: 12px;
border: 4px solid transparent;
background-image: linear-gradient(
180deg,
var(--theme-gradient-start) 0%,
var(--theme-gradient-end) 100%
);
background-origin: border-box;
background-clip: border-box;
-webkit-mask: linear-gradient(#fff 0 0) padding-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
z-index: 1;
}
.styled-button:after {
content: "";
position: absolute;
inset: 2px;
border-radius: 12px;
border: 2px solid #0d1e4c;
z-index: 2;
}
.styled-button-text {
color: white;
font-size: 14px;
font-weight: 800;
text-shadow: 2px 2px 0 rgba(0, 0, 0, 0.35);
text-transform: uppercase;
letter-spacing: 1px;
z-index: 2;
position: relative;
}
.styled-button:active {
transform: translateY(6px);
box-shadow: 0 0 0 #000000, inset 0 -2px 12px rgba(0, 0, 0, 0.2),
inset 0 2px 12px rgba(255, 255, 255, 0.4);
}
.styled-button-icon {
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
flex-grow: 0;
padding: 0px;
width: 30px;
height: 30px;
background: rgba(42, 88, 173, 0.25);
border-radius: 999px;
z-index: 1;
}
.styled-button-icon:after {
pointer-events: none;
content: "";
position: absolute;
inset: 0px;
background: rgba(3, 85, 188, 0.5);
border-radius: 999px;
border: 2px solid #0a82d1;
z-index: -1;
}
@media (min-width: 1024px) {
.styled-button {
height: 60px;
}
.styled-button-text {
font-size: 16px;
}
}
.spinner {
width: 16px;
height: 16px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
.button {
appearance: none;
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 6px;
cursor: pointer;
background-color: rgba(0, 0, 0, 0.15);
color: #ffffff;
font-weight: 800;
padding: 0 12px;
height: var(--button-height-sm);
border-radius: 999px;
font-size: 16px;
&.outline {
border: 2px solid rgba(255, 255, 255, 0.35);
background-color: transparent;
outline: none;
transition: background-color 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
&.outline:hover {
background-color: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.5);
}
&.ghost {
background-color: var(--color-slate-100);
color: var(--color-slate-600);
height: var(--button-height-sm);
}
&.ghost:hover {
background-color: var(--color-slate-200);
}
}
@media (min-width: 1024px) {
.button {
padding: 0 24px;
gap: 12px;
height: var(--button-height);
}
}
/* Animations */
@keyframes zoom-bounce {
0% {
transform: scale(0.25);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
50% {
transform: scale(1.1);
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
}
75% {
transform: scale(0.95);
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
}
100% {
transform: scale(1);
}
}

View File

@@ -0,0 +1,18 @@
export type PersonalityType =
| 'friendly'
| 'professional'
| 'enthusiastic'
| 'thoughtful'
| 'witty';
// This object can be useful for displaying user-friendly labels or descriptions
export const PERSONALITY_PRESETS: Record<PersonalityType, string> = {
friendly: 'Friendly',
professional: 'Professional',
enthusiastic: 'Enthusiastic',
thoughtful: 'Thoughtful',
witty: 'Witty',
};
// Default personality to use
export const DEFAULT_PERSONALITY: PersonalityType = 'witty';

View File

@@ -0,0 +1,8 @@
/**
* Formats seconds into MM:SS format
*/
export function formatTime(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs < 10 ? '0' : ''}${secs}`;
}

View File

@@ -0,0 +1,37 @@
/**
* Safely clears any type of timer
* @param timer The timer to clear
* @returns null to reassign to the timer reference
*/
export function clearTimer(timer: NodeJS.Timeout | null): null {
if (timer) {
clearTimeout(timer);
}
return null;
}
/**
* Creates a countdown timer that calls the callback every second
* @returns A function to stop the timer
*/
export function createCountdownTimer(
durationSeconds: number,
onTick: (secondsLeft: number) => void,
onComplete: () => void
): () => void {
let secondsLeft = durationSeconds;
const timer = setInterval(() => {
secondsLeft--;
onTick(secondsLeft);
if (secondsLeft <= 0) {
clearInterval(timer);
onComplete();
}
}, 1000);
return () => {
clearInterval(timer);
};
}

View File

@@ -0,0 +1,37 @@
import { TRANSCRIPT_PATTERNS } from '@/constants/gameConstants';
/**
* Checks if a transcript contains a correct guess for the target word
*/
export function detectWordGuess(transcript: string, targetWord: string) {
const currentWordLower = targetWord.toLowerCase().trim();
// Primary detection: Look for explicit guesses
const guessPattern = TRANSCRIPT_PATTERNS.GUESS_PATTERN;
const guessMatch = transcript.match(guessPattern);
if (guessMatch) {
// Extract the guessed word from whichever group matched (group 1 or 2)
let guessedWord = (guessMatch[1] || guessMatch[2] || '')
.toLowerCase()
.trim();
// Remove articles ("a", "an", "the") from the beginning of the guessed word
guessedWord = guessedWord.replace(/^(a|an|the)\s+/i, '');
return {
isCorrect: guessedWord === currentWordLower,
isExplicitGuess: true,
guessedWord,
};
}
// Secondary detection: Check if word appears in transcript
const containsWord = transcript.toLowerCase().includes(currentWordLower);
return {
isCorrect: containsWord,
isExplicitGuess: false,
guessedWord: containsWord ? targetWord : null,
};
}