Tools
Tools: I Built a GeoGuessr for Languages — Here's How I Made It Speak 8 Languages Overnight
2026-02-23
0 views
admin
The problem: language learning is boring, and i18n is painful ## What is LinguaGuessr? ## Tech stack ## The game mechanics — under the hood ## Audio: a three-tier fallback ## Map: Leaflet with custom pins ## Scoring: Haversine formula + exponential decay ## Multiplayer with Supabase Realtime ## Making it multilingual with Lingo.dev ## The Compiler: auto-translate JSX at build time ## The SDK: runtime translation for dynamic content ## CI/CD: auto-translate on every push ## The language switcher UX ## Bugs I hit (and how I fixed them) ## SVG icons turning into gibberish ## No loading feedback on language switch ## Dev widget stuck on screen ## The lesson ## What I learned ## Try it yourself I've sunk embarrassing hours into GeoGuessr. There's something deeply satisfying about squinting at a road sign in Cyrillic, spotting a right-hand-drive car, and triumphantly dropping a pin somewhere in rural Bulgaria. One evening, while half-listening to a Turkish podcast I didn't understand, it clicked — what if the clue wasn't a photo of a street, but the sound of a language? Think about it. You hear someone speaking. The rhythm, the vowels, the melody of the sentence. Can you tell Japanese from Korean? Portuguese from Spanish? Hindi from Urdu? That question became LinguaGuessr — a game where you listen to a language, pin its origin on a world map, and get scored by how close you land. This is the story of how I built it, the tech decisions that shaped it, and how I made the entire UI speak 8 languages overnight — using Lingo.dev. Let's be honest — most language learning apps are glorified flashcard decks. Duolingo gamified vocabulary drills, but the core loop is still memorize → recall → repeat. GeoGuessr proved that geography can become a game people play for fun, not obligation. Why hasn't anyone done that for linguistics? I wanted to build something where you experience languages rather than study them. Hear a clip, feel the rhythm, take a guess, learn a fun fact. No textbooks, no streaks, no guilt. But there was a second problem lurking underneath: if you're building a game about languages for a global audience, the UI itself needs to speak the player's language. And anyone who's shipped i18n knows the pain — JSON key files, missing translations, string interpolation bugs, a whole parallel codebase just for text. Building a game is hard enough. Making it multilingual felt like signing up for two projects. The game loop is dead simple — three steps: There are 125+ languages in the database — from the obvious (English, Spanish, Mandarin) to the obscure (Basque, Yoruba, Guarani, Corsican). Each language comes with geographic coordinates, a difficulty rating, and a fun cultural fact that shows up after you guess. The game supports solo mode with a global leaderboard, and multiplayer mode where you create a room, share a code, and compete in real time. Five rounds per game, max 25,000 points, and bragging rights for whoever knows their Amharic from their Tigrinya. Here's what powers LinguaGuessr under the hood: Every choice was deliberate — I wanted a stack that could ship fast, scale to multiplayer, and handle i18n without a separate translation infrastructure. Audio is the core mechanic. If the player can't hear the language, there's no game. So I built a three-tier fallback system: The Web Speech API is underrated. Every modern browser ships with dozens of language voices. It's not perfect — some voices sound robotic, and coverage varies by OS — but for a game where you just need to hear the language, it's more than enough. And the price is right: free. The map uses Leaflet with OpenStreetMap tiles. When a player clicks, a gradient pin drops at their guess location. After scoring, a dashed line draws from their guess to the correct location, giving immediate visual feedback on how close (or far off) they were. The scoring uses the Haversine formula to calculate the great-circle distance between the player's guess and the language's true origin. Then an exponential decay curve converts distance to points: The curve is deliberately forgiving — you don't need to nail the exact country. Within 200km is a perfect 5,000. At 2,000km you still get ~2,500. But by 10,000km you're down to ~50 points. It rewards knowledge without punishing reasonable guesses. I wanted multiplayer from day one. The idea of friends arguing about whether that clip was Finnish or Estonian is too good to skip. Supabase Realtime made this surprisingly simple. The entire multiplayer system runs on presence tracking plus four broadcast events: Here's the core channel setup: The entire multiplayer flow — lobby, gameplay sync, scoreboard — is handled by these events. No custom WebSocket server, no socket.io, no polling. And if Supabase is unavailable? The game gracefully degrades to solo mode with in-memory scores. Here's where it gets fun. I'm building a game about languages. The irony of shipping it in English-only was not lost on me. But I also knew from past projects that i18n is a time sink — extracting strings into JSON files, maintaining translation keys, wiring up a provider, hoping nothing breaks when a new string shows up. Then I found Lingo.dev, and it changed my whole approach. The Lingo.dev Compiler wraps your Next.js build and automatically translates all JSX text content. No string extraction. No JSON key files for your UI text. You write your components in English, and the compiler handles the rest. The setup is minimal — just wrap your Next.js config: That's it. Seven target languages. Every <p>, <h1>, <button>, and <span> in my React components now gets translated at build time into Spanish, French, German, Japanese, Hindi, Arabic, and Portuguese. No t("key") calls. No intl.formatMessage. Just write English and ship globally. Static UI text is only half the story. LinguaGuessr has dynamic content — fun facts about each language that come from the database. These can't be translated at build time because they're loaded at runtime. The Lingo.dev SDK handles this: So when a Japanese-speaking player finishes a round, the fun fact about Basque being a language isolate shows up in Japanese. The static UI was already in Japanese from the compiler. The dynamic content gets translated on-the-fly by the SDK. The player sees a fully localized experience. For static locale files (language names, country names, error messages), I use the Lingo.dev CLI paired with a GitHub Action: Every time I update the English source strings and push to main, the Action translates everything and commits the results. No manual translation step, no stale translations, no forgotten locales. On the frontend, switching languages is instant. The useLingoContext hook from Lingo's React integration provides locale and setLocale. A dropdown in the navbar lets you pick any of the 8 languages: I also built a custom toast notification that shows a brief "Translating to Japanese..." message with a spinner when switching — it gives the player feedback that something is happening, even though the switch is nearly instant. No project is complete without war stories. Here are the ones that cost me the most time. After enabling the Lingo.dev Compiler, I noticed my SVG icons were broken. The globe icon in the navbar was rendering as literal text: "SVG zero, polygon zero..." The compiler was treating SVG attributes like viewBox and strokeLinecap as translatable text and mangling them. The fix: Lingo.dev provides a data-lingo-skip attribute. Slap it on any element you don't want translated. I went through every SVG in the codebase and added it: This became a pattern — every decorative SVG, every icon, every non-text element gets data-lingo-skip. It's a small thing, but missing even one SVG can break a whole page. The first time someone switched languages, nothing visually happened for a beat. The UI just... changed. Users thought it was broken. I built the TranslationToast component — a small notification that slides in from the bottom-right with a spinner and auto-dismisses after 3 seconds: Small touch, big UX difference. Lingo.dev ships a developer widget that overlays your app in development — useful for debugging translations, but it kept showing up in production screenshots. The fix was a one-liner in the provider config: Compiler-based i18n tools are powerful. They eliminate the drudgery of string extraction and key management. But you need to tell them what not to translate. SVGs, code blocks, brand names, technical terms — anything that shouldn't be localized needs an explicit skip marker. Once I internalized that pattern, the rest was smooth. Compiler-based i18n is a different paradigm. Traditional i18n (react-intl, next-intl, i18next) is key-based: extract every string, assign a key, look it up at runtime. Lingo.dev's compiler approach inverts this — you write natural JSX, and translation happens at the build layer. It's faster to set up, easier to maintain, and eliminates an entire category of "forgot to extract this string" bugs. Supabase Realtime is underrated for quick multiplayer. I expected to need a dedicated WebSocket server. Instead, four broadcast events and presence tracking gave me a complete multiplayer system. The channel API is clean, the latency is low, and the free tier is generous. Web Speech API is a zero-cost audio solution. It's not studio quality, but for a game where the point is to identify a language, it's perfect. Dozens of language voices, built into every modern browser, no API keys, no usage fees. Building for the world from day one changes how you think about UX. When you know your UI will be in Arabic (right-to-left!) and Japanese (longer text strings!), you design differently. Buttons need flexible widths. Text can't be hardcoded into fixed layouts. It's a constraint that makes you a better designer. LinguaGuessr is live and free to play. Live demo URL: https://linguaguessr.vercel.app/
GitHub repo URL : https://github.com/manjunathpatil/linguaguessr Pick a language. Drop a pin. See how close you get. If you speak one of the 125+ languages in the database and catch a wrong coordinate or a bad fun fact, open an issue — the whole point is making this better together. Built for the Lingo.dev Hackathon. If you're building anything multilingual, seriously check out their compiler. It turned what I expected to be a week of i18n plumbing into an evening of configuration. Built by Manjunath Patil with Next.js, Supabase, Leaflet, and Lingo.dev. Templates let you quickly answer FAQs or store snippets for re-use. Are you sure you want to hide this comment? It will become hidden in your post, but will still be visible via the comment's permalink. Hide child comments as well For further actions, you may consider blocking this person and/or reporting abuse COMMAND_BLOCK:
// Try MP3 first, fall back to Web Speech API
if (audioUrl) { audioRef.current = new Audio(audioUrl); audioRef.current.onerror = () => { setUseWebSpeech(true); playWithWebSpeech(); }; audioRef.current.play().catch(() => { setUseWebSpeech(true); playWithWebSpeech(); });
} else { playWithWebSpeech();
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// Try MP3 first, fall back to Web Speech API
if (audioUrl) { audioRef.current = new Audio(audioUrl); audioRef.current.onerror = () => { setUseWebSpeech(true); playWithWebSpeech(); }; audioRef.current.play().catch(() => { setUseWebSpeech(true); playWithWebSpeech(); });
} else { playWithWebSpeech();
} COMMAND_BLOCK:
// Try MP3 first, fall back to Web Speech API
if (audioUrl) { audioRef.current = new Audio(audioUrl); audioRef.current.onerror = () => { setUseWebSpeech(true); playWithWebSpeech(); }; audioRef.current.play().catch(() => { setUseWebSpeech(true); playWithWebSpeech(); });
} else { playWithWebSpeech();
} CODE_BLOCK:
export function haversineDistance( lat1: number, lng1: number, lat2: number, lng2: number
): number { const R = 6371; // Earth's radius in km const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} export function calculateScore(distanceKm: number): number { if (distanceKm <= 200) return 5000; // Perfect score // Exponential decay: forgiving but steep return Math.max(0, Math.round(5000 * Math.exp(-distanceKm / 3000)));
} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
export function haversineDistance( lat1: number, lng1: number, lat2: number, lng2: number
): number { const R = 6371; // Earth's radius in km const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} export function calculateScore(distanceKm: number): number { if (distanceKm <= 200) return 5000; // Perfect score // Exponential decay: forgiving but steep return Math.max(0, Math.round(5000 * Math.exp(-distanceKm / 3000)));
} CODE_BLOCK:
export function haversineDistance( lat1: number, lng1: number, lat2: number, lng2: number
): number { const R = 6371; // Earth's radius in km const dLat = toRad(lat2 - lat1); const dLng = toRad(lng2 - lng1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng / 2) ** 2; return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
} export function calculateScore(distanceKm: number): number { if (distanceKm <= 200) return 5000; // Perfect score // Exponential decay: forgiving but steep return Math.max(0, Math.round(5000 * Math.exp(-distanceKm / 3000)));
} COMMAND_BLOCK:
const channel = supabase.channel(`room:${roomCode}`, { config: { presence: { key: player.name } },
}); channel .on("presence", { event: "sync" }, () => { const state = channel.presenceState(); // Update player list from presence state }) .on("broadcast", { event: "game_start" }, ({ payload }) => { setLanguages(payload.languages); setPhase("playing"); }) .on("broadcast", { event: "guess_submitted" }, ({ payload }) => { // Update scoreboard with player's round score }) .on("broadcast", { event: "next_round" }, ({ payload }) => { setCurrentRound(payload.round); setPhase("playing"); }) .on("broadcast", { event: "game_finished" }, () => { setPhase("finished"); }) .subscribe(); Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
const channel = supabase.channel(`room:${roomCode}`, { config: { presence: { key: player.name } },
}); channel .on("presence", { event: "sync" }, () => { const state = channel.presenceState(); // Update player list from presence state }) .on("broadcast", { event: "game_start" }, ({ payload }) => { setLanguages(payload.languages); setPhase("playing"); }) .on("broadcast", { event: "guess_submitted" }, ({ payload }) => { // Update scoreboard with player's round score }) .on("broadcast", { event: "next_round" }, ({ payload }) => { setCurrentRound(payload.round); setPhase("playing"); }) .on("broadcast", { event: "game_finished" }, () => { setPhase("finished"); }) .subscribe(); COMMAND_BLOCK:
const channel = supabase.channel(`room:${roomCode}`, { config: { presence: { key: player.name } },
}); channel .on("presence", { event: "sync" }, () => { const state = channel.presenceState(); // Update player list from presence state }) .on("broadcast", { event: "game_start" }, ({ payload }) => { setLanguages(payload.languages); setPhase("playing"); }) .on("broadcast", { event: "guess_submitted" }, ({ payload }) => { // Update scoreboard with player's round score }) .on("broadcast", { event: "next_round" }, ({ payload }) => { setCurrentRound(payload.round); setPhase("playing"); }) .on("broadcast", { event: "game_finished" }, () => { setPhase("finished"); }) .subscribe(); COMMAND_BLOCK:
// next.config.ts
import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = { images: { unoptimized: true },
}; export default async function (): Promise<NextConfig> { return await withLingo(nextConfig, { sourceLocale: "en", targetLocales: ["es", "fr", "de", "ja", "hi", "ar", "pt"], models: "lingo.dev", });
} Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
// next.config.ts
import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = { images: { unoptimized: true },
}; export default async function (): Promise<NextConfig> { return await withLingo(nextConfig, { sourceLocale: "en", targetLocales: ["es", "fr", "de", "ja", "hi", "ar", "pt"], models: "lingo.dev", });
} COMMAND_BLOCK:
// next.config.ts
import { withLingo } from "@lingo.dev/compiler/next"; const nextConfig: NextConfig = { images: { unoptimized: true },
}; export default async function (): Promise<NextConfig> { return await withLingo(nextConfig, { sourceLocale: "en", targetLocales: ["es", "fr", "de", "ja", "hi", "ar", "pt"], models: "lingo.dev", });
} CODE_BLOCK:
import { LingoDotDevEngine } from "lingo.dev/sdk"; const engine = new LingoDotDevEngine({ apiKey: process.env.LINGODODEV_API_KEY,
}); const translated = await engine.localizeText(funFact, { sourceLocale: "en", targetLocale: userLocale,
}); Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
import { LingoDotDevEngine } from "lingo.dev/sdk"; const engine = new LingoDotDevEngine({ apiKey: process.env.LINGODODEV_API_KEY,
}); const translated = await engine.localizeText(funFact, { sourceLocale: "en", targetLocale: userLocale,
}); CODE_BLOCK:
import { LingoDotDevEngine } from "lingo.dev/sdk"; const engine = new LingoDotDevEngine({ apiKey: process.env.LINGODODEV_API_KEY,
}); const translated = await engine.localizeText(funFact, { sourceLocale: "en", targetLocale: userLocale,
}); COMMAND_BLOCK:
# .github/workflows/translate.yml
on: push: branches: [main] paths: ['src/locales/en.json', 'i18n.json'] jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Lingo.dev CLI run: npx lingo.dev@latest i18n env: LINGODODEV_API_KEY: ${{ secrets.LINGODODEV_API_KEY }} - name: Commit translations run: | git add src/locales/ git diff --staged --quiet || git commit -m "chore: update translations" git push Enter fullscreen mode Exit fullscreen mode COMMAND_BLOCK:
# .github/workflows/translate.yml
on: push: branches: [main] paths: ['src/locales/en.json', 'i18n.json'] jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Lingo.dev CLI run: npx lingo.dev@latest i18n env: LINGODODEV_API_KEY: ${{ secrets.LINGODODEV_API_KEY }} - name: Commit translations run: | git add src/locales/ git diff --staged --quiet || git commit -m "chore: update translations" git push COMMAND_BLOCK:
# .github/workflows/translate.yml
on: push: branches: [main] paths: ['src/locales/en.json', 'i18n.json'] jobs: translate: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Run Lingo.dev CLI run: npx lingo.dev@latest i18n env: LINGODODEV_API_KEY: ${{ secrets.LINGODODEV_API_KEY }} - name: Commit translations run: | git add src/locales/ git diff --staged --quiet || git commit -m "chore: update translations" git push CODE_BLOCK:
const { locale, setLocale } = useLingoContext(); // When user picks a language
setLocale("ja"); // Switches entire UI to Japanese Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
const { locale, setLocale } = useLingoContext(); // When user picks a language
setLocale("ja"); // Switches entire UI to Japanese CODE_BLOCK:
const { locale, setLocale } = useLingoContext(); // When user picks a language
setLocale("ja"); // Switches entire UI to Japanese CODE_BLOCK:
<svg data-lingo-skip width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="10" /> <path d="M2 12h20" />
</svg> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
<svg data-lingo-skip width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="10" /> <path d="M2 12h20" />
</svg> CODE_BLOCK:
<svg data-lingo-skip width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"> <circle cx="12" cy="12" r="10" /> <path d="M2 12h20" />
</svg> CODE_BLOCK:
<div className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 shadow-2xl"> <svg data-lingo-skip className="h-4 w-4 animate-spin text-accent" /* ... */ /> <span>Translating to {languageName}...</span>
</div> Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
<div className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 shadow-2xl"> <svg data-lingo-skip className="h-4 w-4 animate-spin text-accent" /* ... */ /> <span>Translating to {languageName}...</span>
</div> CODE_BLOCK:
<div className="fixed bottom-6 right-6 z-[100] flex items-center gap-3 rounded-xl border border-border bg-surface px-4 py-3 shadow-2xl"> <svg data-lingo-skip className="h-4 w-4 animate-spin text-accent" /* ... */ /> <span>Translating to {languageName}...</span>
</div> CODE_BLOCK:
devWidget={{ enabled: false }} Enter fullscreen mode Exit fullscreen mode CODE_BLOCK:
devWidget={{ enabled: false }} CODE_BLOCK:
devWidget={{ enabled: false }} - Listen — Hear a clip of someone speaking a mystery language
- Pin — Click anywhere on the world map to place your guess
- Score — The closer your pin to the language's true origin, the more points you earn (max 5,000 per round) - MP3 files — Pre-recorded clips for supported languages
- Web Speech API — Browser-native text-to-speech as a fallback (free, zero-cost, surprisingly good)
- Text display — If both fail, show the phrase on screen and let the player guess from the script
how-totutorialguidedev.toaimlubuntuserverswitchdatabasegitgithub