DEV Community

Cover image for # ๐Ÿ” Building Polyglot Escape: When Translation Becomes The Game
Bhagyesh Patil
Bhagyesh Patil

Posted on

# ๐Ÿ” Building Polyglot Escape: When Translation Becomes The Game

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 setInterval and 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:

  1. It's faster to build
  2. CSS handles all the rendering
  3. 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);
}, []);
Enter fullscreen mode Exit fullscreen mode

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"
  }
];
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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>
      )}
    </>
  );
}
Enter fullscreen mode Exit fullscreen mode

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]';
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  },
}));
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode


๐Ÿ”ฅ 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! */
}
Enter fullscreen mode Exit fullscreen mode

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)