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.
The problem: language learning is boring, and i18n is painful
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.
What is LinguaGuessr?
The game loop is dead simple — three steps:
- 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)
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.
Tech stack
Here's what powers LinguaGuessr under the hood:
| Layer | Technology | Why |
|---|---|---|
| Framework | Next.js 16 (App Router) | Server components, API routes, fast builds |
| Styling | Tailwind CSS v4 | Utility-first, dark theme, zero CSS files |
| Map | Leaflet + react-leaflet | Open-source, lightweight, great mobile support |
| Realtime | Supabase (Postgres + Realtime) | Room codes, presence, broadcast — all in one |
| i18n | Lingo.dev (Compiler + SDK + CLI + CI/CD) | Build-time JSX translation, runtime dynamic translation |
| Audio | Web Speech API | Zero-cost TTS with native browser voices |
| Language | TypeScript | Type safety across the entire stack |
Every choice was deliberate — I wanted a stack that could ship fast, scale to multiplayer, and handle i18n without a separate translation infrastructure.
The game mechanics — under the hood
Audio: a three-tier fallback
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:
- 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
// 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();
}
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.
Map: Leaflet with custom pins
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.
Scoring: Haversine formula + exponential decay
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:
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)));
}
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.
Multiplayer with Supabase Realtime
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:
| Event | Purpose |
|---|---|
presence:sync |
Track who's in the room |
game_start |
Host starts the game, sends the language list to all players |
guess_submitted |
A player submits their guess and score |
next_round |
Advance everyone to the next round |
game_finished |
Show final rankings |
Here's the core channel setup:
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();
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.
Making it multilingual with Lingo.dev
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 Compiler: auto-translate JSX at build time
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:
// 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",
});
}
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.
The SDK: runtime translation for dynamic content
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:
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,
});
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.
CI/CD: auto-translate on every push
For static locale files (language names, country names, error messages), I use the Lingo.dev CLI paired with a GitHub Action:
# .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
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.
The language switcher UX
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:
const { locale, setLocale } = useLingoContext();
// When user picks a language
setLocale("ja"); // Switches entire UI to Japanese
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.
Bugs I hit (and how I fixed them)
No project is complete without war stories. Here are the ones that cost me the most time.
SVG icons turning into gibberish
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:
<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>
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.
No loading feedback on language switch
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:
<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>
Small touch, big UX difference.
Dev widget stuck on screen
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:
devWidget={{ enabled: false }}
The lesson
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.
What I learned
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.
Try it yourself
LinguaGuessr is live and free to play.
Live demo URL: https://linguaguessr.vercel.app/
GitHub repo URL : https://github.com/Manjunath3155/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.




Top comments (0)