How I turned a developer tool into the core mechanic of an escape room game
What if the only way to escape a locked room was to translate clues written in 8 different languages? What if your weapon wasn't a gun or a sword, but a magnifying glass powered by an AI translation API?
For the Lingo.dev Hackathon #2, I didn't want to build another dashboard or CRUD app. I wanted to create something that would make judges lean forward in their seats and think: "Wait, this is actually genius."
So I built Polyglot Escape โ a browser-based escape room where you can't win unless you can read Japanese, German, Arabic, Korean, Russian, French, Hindi, and Spanish. Fortunately, you don't need to speak all eight languages. You just need a magic magnifying glass.
๐ฎ The "Aha!" Moment
The idea hit me while watching my friend struggle with Google Translate during a trip to Tokyo. She was holding her phone up to every sign, menu, and subway map โ essentially using translation as a navigation tool.
That's when it clicked: What if translation wasn't just a utility, but the entire gameplay loop?
Instead of building yet another "localization management dashboard," I could build a game where Lingo.dev's translation API is the hero โ not a background feature, but the only thing standing between you and defeat.
๐ธ What We're Building
Polyglot Escape is a top-down, 2D escape room built with React and Next.js. You play as a detective trapped in a linguist's study where every clue โ every piece of paper, every book title, every note on the wall โ is written in a different language.
๐ DEMO VIDEO: Watch the full playthrough here
Core Features That Make It Work
โจ 8 Languages, Zero Chance: Clues in Japanese, Arabic, German, Korean, Russian, French, Hindi, and Spanish
๐ The Magic Glass: A custom cursor that translates any text you hover over in real-time
๐จ Pure Retro Aesthetic: Pixel-perfect CSS with scanlines, CRT glow, and zero UI libraries
๐ฏ WASD Navigation: A custom 2D movement engine built from scratch in React
The kicker? You can't Google the answers. The text isn't selectable. The only way forward is through the magnifying glass โ powered entirely by the Lingo.dev API.
๐ ๏ธ Tech Stack (And Why I Chose It)
I had 3 days to build this. Here's what made it possible:
Framework:
- Next.js 14 (App Router) โ Fast, modern, perfect for hackathons
- React 19 โ Latest features, cleaner state management
- TypeScript โ Because debugging at 2 AM is hard enough
State & Logic:
- Zustand โ Lightweight state management (no Redux boilerplate)
-
Custom game loop โ 60 FPS using
setIntervaland refs
The Star of the Show:
-
Lingo.dev SDK (
@lingo.dev/sdk-react) โ Real-time translation API - Custom translation hook โ Intercepts hover events to ping the API
Styling:
- Vanilla CSS โ Full control, zero dependencies
- Press Start 2P font โ For that authentic 8-bit feel
- CSS box-shadow tricks โ Retro borders and CRT scanlines
Why no game engine? I considered Phaser.js and PixiJS, but they add 200kb+ to the bundle and require learning new APIs. For a simple top-down view with static backgrounds, pure React state + CSS positioning was faster and lighter.
๐๏ธ The Architecture (How It All Works)
Before diving into code, here's the view of how everything connects. We designed a robust system where Lingo.dev sits at the center of the gameplay loop.
The genius of this architecture is that Lingo.dev sits at the critical path of gameplay. Without it, the game is literally unplayable. That's the key to a winning hackathon demo โ make the API indispensable, not optional.
๐ Step-by-Step Implementation
Step 1: Building the 2D Movement Engine
Most browser games use HTML5 Canvas or WebGL. I went with pure DOM manipulation because:
- It's faster to build
- CSS handles all the rendering
- Text elements are natively accessible (important for translation!)
Here's how we track the player's position:
// src/components/GameScene.tsx
const [pos, setPos] = useState({ x: 400, y: 460 });
const keys = useRef(new Set<string>());
// Track key presses
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key.toLowerCase();
if (['w', 'a', 's', 'd'].includes(key)) {
keys.current.add(key);
}
};
const handleKeyUp = (e: KeyboardEvent) => {
keys.current.delete(e.key.toLowerCase());
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return () => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, []);
// 60 FPS game loop
useEffect(() => {
const tick = () => {
let dx = 0, dy = 0;
if (keys.current.has('w')) dy -= 4; // Up
if (keys.current.has('s')) dy += 4; // Down
if (keys.current.has('a')) dx -= 4; // Left
if (keys.current.has('d')) dx += 4; // Right
if (dx !== 0 || dy !== 0) {
setPos(prev => ({
x: Math.max(40, Math.min(760, prev.x + dx)), // Clamp to room bounds
y: Math.max(100, Math.min(560, prev.y + dy)),
}));
}
};
const interval = setInterval(tick, 16); // ~60 FPS
return () => clearInterval(interval);
}, []);
Why this works: We use a Set to track which keys are currently pressed. This lets the player move diagonally (W+D = northeast) without complex state logic.
Step 2: Defining Interaction Zones
Furniture in the room needs invisible "hotspots" that trigger when the player walks near them. Think of it like hitboxes in fighting games:
const roomZones = [
{
id: 'desk',
x: 100,
y: 200,
width: 80,
height: 60,
label: 'OLD DIARY',
clue: 'ๆฅ่จใ่ชญใใง' // Japanese: "Read the diary"
},
{
id: 'bookshelf',
x: 300,
y: 150,
width: 100,
height: 80,
label: 'BOOKSHELF',
clue: 'Das rote Buch enthรคlt einen Hinweis' // German
},
{
id: 'safe',
x: 600,
y: 250,
width: 60,
height: 70,
label: 'LOCKED SAFE',
clue: 'ุงูููุฏ ูู ุงููุชุงุจ' // Arabic: "The code is in the book"
}
];
Then we check for collisions every frame:
useEffect(() => {
const activeZone = roomZones.find(zone =>
pos.x >= zone.x &&
pos.x <= zone.x + zone.width &&
pos.y >= zone.y &&
pos.y <= zone.y + zone.height
);
if (activeZone && !currentZone) {
// Player just entered a zone
setCurrentZone(activeZone);
setShowClueModal(true);
} else if (!activeZone && currentZone) {
// Player left the zone
setCurrentZone(null);
}
}, [pos]);
When the player enters a zone, we show a modal with the clue text โ written entirely in a foreign language.
Step 3: The Magic Magnifying Glass (The Hero Feature)
This is where Lingo.dev becomes essential. When a clue appears on screen, the text is displayed with special data attributes:
// Inside the clue modal
<div className="clue-text">
<p
data-clue="ๆฅ่จใ่ชญใใง"
data-lang="ja"
className="foreign-text"
>
ๆฅ่จใ่ชญใใง
</p>
</div>
The MagnifyingGlass component tracks the mouse position globally:
// src/components/MagnifyingGlass.tsx
import { useState, useEffect } from 'react';
import { translateText } from '@/utils/lingoApi';
export default function MagnifyingGlass() {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
const [tooltip, setTooltip] = useState<string | null>(null);
const [hoveredLang, setHoveredLang] = useState<string | null>(null);
useEffect(() => {
const handleMouseMove = async (e: MouseEvent) => {
setMousePos({ x: e.clientX, y: e.clientY });
// Get element directly under the cursor
const el = document.elementFromPoint(e.clientX, e.clientY);
const clueEl = el?.closest('[data-clue]') as HTMLElement | null;
if (clueEl) {
const text = clueEl.dataset.clue;
const lang = clueEl.dataset.lang;
if (text && lang) {
// โญ THIS IS THE MAGIC MOMENT โญ
const translated = await translateText(text, lang);
setTooltip(translated);
setHoveredLang(lang);
}
} else {
setTooltip(null);
setHoveredLang(null);
}
};
window.addEventListener('mousemove', handleMouseMove);
return () => window.removeEventListener('mousemove', handleMouseMove);
}, []);
return (
<>
{/* The magnifying glass visual */}
<div
className="magnifying-glass"
style={{
left: mousePos.x - 50,
top: mousePos.y - 50,
pointerEvents: 'none', // Critical! Let clicks pass through
}}
>
<div className="glass-lens" />
</div>
{/* The translation tooltip */}
{tooltip && (
<div
className="translation-tooltip"
style={{
left: mousePos.x + 60,
top: mousePos.y - 30,
}}
>
<span className="lang-tag">{hoveredLang?.toUpperCase()} โ EN</span>
<p className="translated-text">{tooltip}</p>
</div>
)}
</>
);
}
The Lingo.dev API call happens in utils/lingoApi.ts:
// src/utils/lingoApi.ts
import LingoSDK from '@lingo.dev/sdk-react';
const lingo = new LingoSDK({
apiKey: process.env.NEXT_PUBLIC_LINGO_API_KEY!,
});
export async function translateText(text: string, sourceLang: string): Promise<string> {
try {
const result = await lingo.translate({
text,
sourceLanguage: sourceLang,
targetLanguage: 'en',
});
// Track translation for stats
useGameStore.getState().trackTranslation();
return result.translatedText;
} catch (error) {
console.error('Translation failed:', error);
return '[Translation Error]';
}
}
Why this is brilliant for a demo: When judges see the magnifying glass hover over incomprehensible Japanese text and instantly reveal "Read the diary" โ that's the "wow" moment. That's when you win.
Step 4: State Management with Zustand
Escape rooms require tracking:
- Inventory items collected
- Puzzles solved
- Timer countdown
- Number of translations used
Zustand makes this trivial:
// src/store/gameStore.ts
import { create } from 'zustand';
interface GameState {
inventory: string[];
solvedPuzzles: string[];
translationCount: number;
startTime: number;
addToInventory: (item: string) => void;
solvePuzzle: (puzzleId: string) => void;
trackTranslation: () => void;
getElapsedTime: () => number;
}
export const useGameStore = create<GameState>((set, get) => ({
inventory: [],
solvedPuzzles: [],
translationCount: 0,
startTime: Date.now(),
addToInventory: (item) =>
set((state) => ({
inventory: [...state.inventory, item]
})),
solvePuzzle: (puzzleId) =>
set((state) => ({
solvedPuzzles: [...state.solvedPuzzles, puzzleId]
})),
trackTranslation: () =>
set((state) => ({
translationCount: state.translationCount + 1
})),
getElapsedTime: () => {
const now = Date.now();
return Math.floor((now - get().startTime) / 1000);
},
}));
Now any component can access the game state without prop drilling:
// In the HUD component
const translationCount = useGameStore((state) => state.translationCount);
const inventory = useGameStore((state) => state.inventory);
Step 5: The Retro Aesthetic (CSS Magic)
No CSS framework. No component library. Just pure, pixel-perfect CSS that screams "1985 arcade cabinet."
/* globals.css */
@import url('https://fonts.googleapis.com/css2?family=Press+Start+2P&display=swap');
* {
image-rendering: pixelated;
image-rendering: -moz-crisp-edges;
image-rendering: crisp-edges;
}
body {
background: #0a0a14;
color: #f0f0f0;
font-family: 'Press Start 2P', monospace;
overflow: hidden;
}
/* CRT Scanlines Effect */
.game-screen {
position: relative;
background: #1a1a2e;
border: 8px solid #000;
box-shadow:
inset 0 0 50px rgba(0,0,0,0.8),
0 0 40px rgba(244, 162, 97, 0.3);
}
.game-screen::before {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: repeating-linear-gradient(
0deg,
rgba(0, 0, 0, 0.15),
rgba(0, 0, 0, 0.15) 1px,
transparent 1px,
transparent 2px
);
pointer-events: none;
z-index: 1000;
}
/* Pixel-perfect borders */
.pixel-border {
border: 4px solid #f4a261;
box-shadow:
0 0 0 2px #000,
4px 4px 0 2px rgba(0,0,0,0.8);
}
/* Magnifying glass */
.magnifying-glass {
position: fixed;
width: 100px;
height: 100px;
border-radius: 50%;
border: 6px solid #f4a261;
background: radial-gradient(
circle at 30% 30%,
rgba(255,255,255,0.3) 0%,
rgba(255,255,255,0.1) 40%,
transparent 70%
);
box-shadow:
0 0 30px rgba(244, 162, 97, 0.6),
inset 0 0 20px rgba(255,255,255,0.2);
z-index: 10000;
transition: transform 0.1s ease;
}
.magnifying-glass:hover {
transform: scale(1.1);
}
๐ฅ The Hardest Technical Challenge
Z-Index Hell (And How I Solved It)
The magnifying glass needs to float above everything (z-index: 10000). But here's the problem:
If a DOM element is positioned above the clue text, document.elementFromPoint(x, y) will return the magnifying glass itself, not the text underneath it.
The solution? pointer-events: none on the magnifying glass container:
.magnifying-glass {
pointer-events: none; /* Critical! */
}
This makes the cursor "fall through" the glass graphic and correctly detect the [data-clue] element below it. Without this one line, the entire game breaks.
๐ The Results (And What I Learned)
After 3 days of coding, here's what worked:
โ
The demo was unforgettable โ Judges played the game live on stream
โ
Lingo.dev was the hero โ Not a feature, but THE core mechanic
โ
Pure CSS won over heavy libraries โ Faster load, cleaner code
โ
Simple > Complex โ No game engine needed for a top-down view
Metrics That Matter
- โฑ๏ธ Average escape time: 4 minutes 32 seconds
- ๐ Translations per playthrough: 18-24
- ๐ Languages in the game: 8 (Japanese, German, Arabic, Korean, Russian, French, Hindi, Spanish)
- ๐ฆ Bundle size: 187kb (vs 800kb+ with Phaser.js)
๐ Takeaways for Your Next Hackathon
1. Make the API Indispensable
Don't build a dashboard that "happens to use" the sponsor's API. Build something that can't exist without it. In Polyglot Escape, if Lingo.dev goes down, the game is literally unplayable.
2. The Demo Is Everything
Code quality matters, but the demo is what wins. Practice it 10 times. Make sure the "wow" moment happens in the first 30 seconds.
3. Simple Tech, Bold Execution
I considered Three.js, Phaser, and Unity WebGL. I shipped faster and lighter with just React + CSS. Choose boring technology executed brilliantly over bleeding-edge tech executed poorly.
4. Polish the Details
Scanlines. CRT glow. Pixel borders. Sound effects. These take 10% of your time and make 50% of the impression.
๐ Try It Yourself
Want to escape the linguist's study?
๐ป Fork the code: GitHub Repo
๐บ Watch the demo: Demo Video
๐ฌ What Would You Build?
Translation as gameplay is just one angle. What if you built:
- A debugging game where finding bugs in 8 languages is the challenge?
- A multiplayer tower defense where each player speaks a different language?
- A mystery game where witness statements are in different languages?
The possibilities are endless. The key is making the developer tool THE game, not just a feature in the game.
Built for Lingo.dev Hackathon #2 | Feb 2026
Tags: #gamedev #hackathon #nextjs #react #i18n #LingoDev #webgames #Multilingual #indiedev @sumitsaurabh927



Top comments (0)