diff --git a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
index 604b95f7..de9f90e9 100644
--- a/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
+++ b/apps/web/src/arcade-games/know-your-world/components/MapRenderer.tsx
@@ -794,8 +794,14 @@ export function MapRenderer({
: selectedContinent !== 'all'
? (selectedContinent as HintMap)
: 'world'
- // Get hint for current region (if available)
- const hintText = useRegionHint(hintMapKey, currentPrompt)
+ // Get hint for current region (if available) - with cycling support
+ const {
+ currentHint: hintText,
+ hintIndex,
+ totalHints,
+ nextHint,
+ hasMoreHints,
+ } = useRegionHint(hintMapKey, currentPrompt)
const hasHint = useHasRegionHint(hintMapKey, currentPrompt)
// Get the current region name for audio hints
@@ -1024,14 +1030,14 @@ export function MapRenderer({
if (delay > 0) {
// Schedule delayed announcement
announcementTimeoutRef.current = setTimeout(() => {
- speakWithRegionName(currentRegionName, null, false)
+ speakWithRegionName(currentRegionName, hintText, false)
announcementTimeoutRef.current = null
}, delay)
} else {
// No recent "You found", announce immediately
- speakWithRegionName(currentRegionName, null, false)
+ speakWithRegionName(currentRegionName, hintText, false)
}
- }, [currentPrompt, currentRegionName, isSpeechSupported, speakWithRegionName])
+ }, [currentPrompt, currentRegionName, hintText, isSpeechSupported, speakWithRegionName])
// Part 2: Announce "You found {region}" at the START of part 2
// - In learning mode: Triggered when puzzlePieceTarget is set (takeover fades back in)
@@ -1123,6 +1129,65 @@ export function MapRenderer({
resetHotCold()
}, [currentPrompt, resetHotCold])
+ // Struggle detection: Give additional hints when user is having trouble
+ // Thresholds for triggering next hint (must have more hints available)
+ const STRUGGLE_TIME_THRESHOLD = 30000 // 30 seconds per hint level
+ const STRUGGLE_CHECK_INTERVAL = 5000 // Check every 5 seconds
+ const lastHintLevelRef = useRef(0) // Track which hint level triggered last hint
+
+ useEffect(() => {
+ // Only run if hot/cold is enabled (means we're tracking search metrics)
+ if (!effectiveHotColdEnabled || !hasMoreHints || !currentPrompt) return
+
+ const checkStruggle = () => {
+ const metrics = getSearchMetrics(promptStartTime.current)
+
+ // Calculate which hint level we should be at based on time
+ // Level 0 = first 30 seconds, Level 1 = 30-60 seconds, etc.
+ const expectedHintLevel = Math.floor(metrics.timeToFind / STRUGGLE_TIME_THRESHOLD)
+
+ // If we should be at a higher hint level than last triggered, give next hint
+ if (expectedHintLevel > lastHintLevelRef.current && hasMoreHints) {
+ lastHintLevelRef.current = expectedHintLevel
+ nextHint()
+ }
+ }
+
+ const intervalId = setInterval(checkStruggle, STRUGGLE_CHECK_INTERVAL)
+
+ return () => clearInterval(intervalId)
+ }, [
+ effectiveHotColdEnabled,
+ hasMoreHints,
+ currentPrompt,
+ getSearchMetrics,
+ nextHint,
+ promptStartTime,
+ ])
+
+ // Reset hint level tracking when prompt changes
+ useEffect(() => {
+ lastHintLevelRef.current = 0
+ }, [currentPrompt])
+
+ // Speak new hint when it changes (due to cycling)
+ const prevHintIndexRef = useRef(hintIndex)
+ useEffect(() => {
+ // Only speak if hint index actually changed (not initial render)
+ if (
+ hintIndex > prevHintIndexRef.current &&
+ hintText &&
+ isSpeechSupported &&
+ currentRegionName
+ ) {
+ prevHintIndexRef.current = hintIndex
+ // Speak just the hint (user already knows the region name)
+ speak(hintText, false)
+ } else if (hintIndex !== prevHintIndexRef.current) {
+ prevHintIndexRef.current = hintIndex
+ }
+ }, [hintIndex, hintText, isSpeechSupported, currentRegionName, speak])
+
// Update context with controls state for GameInfoPanel
useEffect(() => {
setControlsState({
@@ -4202,11 +4267,7 @@ export function MapRenderer({
}}
>
{/* North indicator - red triangle pointing up */}
-
+
@@ -4336,7 +4397,13 @@ export function MapRenderer({
)
})}
{/* Center dot */}
-
+
{/* Counter-rotating group to keep N fixed pointing up */}
{/* North indicator - red triangle pointing up */}
-
+
diff --git a/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts b/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts
index d7e7380c..1197f5e6 100644
--- a/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts
+++ b/apps/web/src/arcade-games/know-your-world/hooks/useRegionHint.ts
@@ -1,53 +1,99 @@
-import { useMemo, useRef } from 'react'
+import { useCallback, useMemo, useRef, useState } from 'react'
import { useLocale } from 'next-intl'
import { getHints, hasHints, type HintMap } from '../messages'
import type { Locale } from '@/i18n/messages'
/**
- * Hook to get a randomly selected hint for a region in the current locale.
- * The hint is selected once when the region changes and stays consistent
- * for that region prompt. Different hints may appear on subsequent attempts
- * at the same region.
+ * Hook to get hints for a region with cycling support.
+ * Starts with a random hint, and can cycle to the next hint when user is struggling.
*
- * Returns null if no hints exist for the region.
+ * Returns:
+ * - currentHint: The current hint text (null if no hints)
+ * - hintIndex: Current index (0-based)
+ * - totalHints: Total number of hints available
+ * - nextHint: Function to advance to the next hint (cycles)
+ * - hasMoreHints: Whether there are hints the user hasn't seen yet
*/
-export function useRegionHint(map: HintMap, regionId: string | null): string | null {
+export interface UseRegionHintResult {
+ currentHint: string | null
+ hintIndex: number
+ totalHints: number
+ nextHint: () => void
+ hasMoreHints: boolean
+}
+
+export function useRegionHint(map: HintMap, regionId: string | null): UseRegionHintResult {
const locale = useLocale() as Locale
- // Track which region we last selected a hint for
+ // Track which region we're showing hints for
const lastRegionRef = useRef(null)
- const selectedHintRef = useRef(null)
+ // Track the starting index (randomized on region change)
+ const startIndexRef = useRef(0)
+ // Track how many hints we've cycled through for this region
+ const [cycleOffset, setCycleOffset] = useState(0)
- // When region changes, select a new random hint
- const hint = useMemo(() => {
- if (!regionId) {
- lastRegionRef.current = null
- selectedHintRef.current = null
- return null
- }
-
- const hints = getHints(locale, map, regionId)
- if (!hints || hints.length === 0) {
- lastRegionRef.current = regionId
- selectedHintRef.current = null
- return null
- }
-
- // If same region, return previously selected hint
- if (regionId === lastRegionRef.current && selectedHintRef.current !== null) {
- return selectedHintRef.current
- }
-
- // New region - select a random hint
- const randomIndex = Math.floor(Math.random() * hints.length)
- const selected = hints[randomIndex]
-
- lastRegionRef.current = regionId
- selectedHintRef.current = selected
-
- return selected
+ // Get all hints for this region
+ const allHints = useMemo(() => {
+ if (!regionId) return []
+ return getHints(locale, map, regionId) ?? []
}, [locale, map, regionId])
- return hint
+ // Reset cycle offset when region changes
+ if (regionId !== lastRegionRef.current) {
+ lastRegionRef.current = regionId
+ // Pick a random starting index for this region
+ startIndexRef.current = allHints.length > 0 ? Math.floor(Math.random() * allHints.length) : 0
+ // Reset cycle offset (can't call setState in render, so we check in useMemo below)
+ }
+
+ // Calculate current hint
+ const result = useMemo(() => {
+ // Reset cycle offset if region changed (detected by mismatch)
+ const effectiveCycleOffset = regionId === lastRegionRef.current ? cycleOffset : 0
+
+ if (allHints.length === 0) {
+ return {
+ currentHint: null,
+ hintIndex: 0,
+ totalHints: 0,
+ hasMoreHints: false,
+ }
+ }
+
+ const currentIndex = (startIndexRef.current + effectiveCycleOffset) % allHints.length
+ return {
+ currentHint: allHints[currentIndex],
+ hintIndex: effectiveCycleOffset,
+ totalHints: allHints.length,
+ hasMoreHints: effectiveCycleOffset < allHints.length - 1,
+ }
+ }, [allHints, cycleOffset, regionId])
+
+ // Reset cycle offset when region changes
+ const prevRegionRef = useRef(null)
+ if (regionId !== prevRegionRef.current) {
+ prevRegionRef.current = regionId
+ if (cycleOffset !== 0) {
+ setCycleOffset(0)
+ }
+ }
+
+ // Function to advance to the next hint
+ const nextHint = useCallback(() => {
+ if (allHints.length > 1) {
+ setCycleOffset((prev) => prev + 1)
+ }
+ }, [allHints.length])
+
+ return {
+ ...result,
+ nextHint,
+ }
+}
+
+// Legacy function signature for backwards compatibility
+export function useRegionHintSimple(map: HintMap, regionId: string | null): string | null {
+ const { currentHint } = useRegionHint(map, regionId)
+ return currentHint
}
/**