CodeQuest RPG
operativoRPG en el navegador donde el combate es resolver código real: escribes JavaScript en un editor de verdad y tu solución se ejecuta en un Web Worker aislado.
Contexto
El proyecto empezó como una prueba de concepto abandonada a medias: una capa de trivia de opción múltiple con temática de RPG, sin persistencia, sin tipado y sin tests. Se retomó con una auditoría técnica honesta y se reconstruyó en fases incrementales, sin dejar el juego roto entre pasos.
- La ambientación
- El mapa de zonas
- La mecánica de trivia de opción múltiple
- El estado disperso entre componentes
- Los archivos sin tipar ni testear
Decisiones técnicas
01key={challenge.id} en vez de useEffect para resetear el editor
La primera versión usaba un useEffect que llamaba a setCode/setStatus al detectar un challenge.id distinto; eslint-plugin-react-hooks lo marcó como antipatrón (un setState síncrono dentro de un efecto provoca renders en cascada). Remontar el subárbol con <ChallengeRunner key={challenge.id} /> hace que React destruya y recree la instancia en cada reto, con estado limpio sin sincronización manual.
src/screens/ChallengeScreen.tsx
− useEffect(() => {− setCode(challenge.starterCode);− setStatus('idle');− setOutput(null);− }, [challenge.id]);
+ <ChallengeRunner key={challenge.id} challenge={challenge} />02TypeScript progresivo, no una reescritura completa
La migración de .js/.jsx a .ts/.tsx se hizo archivo por archivo, verificando build y juego jugable en cada paso, en vez de una reescritura de una sola vez que arriesgaría romper algo a mitad de camino sin darse cuenta.
03Code-splitting de CodeMirror
CodeMirror es, con diferencia, la dependencia más pesada del proyecto. Cargar ChallengeScreen con React.lazy() + Suspense evita que TitleScreen y WorldMap paguen ese coste: el bundle inicial pasó de 734 kB a 216 kB (241,96 kB a 68,77 kB gzip), casi un 70% menos de JS en la carga inicial.
src/App.tsx
− import ChallengeScreen from './screens/ChallengeScreen';+ const ChallengeScreen = lazy(() => import('./screens/ChallengeScreen'));+ // ...+ <Suspense fallback={<Loading />}>+ <ChallengeScreen />+ </Suspense>
Reto técnico
Mismo scope que el resto de la app: un bucle infinito bloquea la UI sin retorno.
en espera
Hilo real y aislado; el principal puede matarlo desde fuera si se cuelga.
en espera
eval() o new Function() en el hilo principal comparte el mismo scope de ejecución que el resto de la app: un bucle infinito bloquea la UI de forma irrecuperable. Un Web Worker es un hilo real y aislado, sin memoria compartida ni acceso al DOM — y, el motivo decisivo, si el jugador escribe un bucle infinito, el hilo principal puede matarlo desde fuera. El propio worker no puede resolver su timeout si está colgado en un bucle síncrono, así que el reloj y el worker.terminate() viven deliberadamente en lib/codeRunner.ts (hilo principal), no dentro del worker.
const worker = new Worker(url, { type: 'module' });
// El reloj vive fuera del worker: si el código del jugador cuelga
// el hilo síncrono, el worker no puede resolver su propio timeout.
const timer = setTimeout(() => {
worker.terminate();
reject(new Error('timeout'));
}, LIMIT_MS);
worker.onmessage = (e) => {
clearTimeout(timer);
resolve(e.data);
};Fases de desarrollo
Auditoría
Diagnóstico honesto de una PoC de trivia abandonada.
El proyecto era una capa de trivia de opción múltiple con temática de RPG: sin persistencia, sin tipado, sin tests. La auditoría decidió qué salvar (la ambientación y el mapa de zonas) y qué tirar por completo (la mecánica de trivia).
commitse9b911f–9a103c6
Resultado
734 kB
216 kB
11 retos de código cubriendo las 6 zonas y los 6 conceptos definidos (variables, condicionales, bucles, arrays, funciones, recursión), con CI en verde (typecheck, lint, Vitest, e2e de Playwright) en cada push. Deliberadamente 100% client-side, sin backend ni cuentas: el progreso vive en localStorage del navegador, con las limitaciones que eso implica y que el propio README documenta sin disimularlas — por ejemplo, que los testCase.hidden no son seguridad real, solo un desincentivo pedagógico.
Stack
React 19
frontendRendimiento y estabilidad para una SPA con pantallas que se montan y desmontan mucho (mapa ↔ reto).
TypeScript (strict)
toolingMigración progresiva desde JS sin tipos, archivo por archivo; strict detecta los mismos bugs de estado que antes solo aparecían jugando.
Vite
toolingArranque y HMR instantáneos, clave para iterar rápido en un proyecto que se reconstruyó por fases incrementales.
Zustand + persist
frontendUn único store organizado por dominios (player, challenge, session, skills) sin la ceremonia de Redux; persist(partialize) guarda solo player/skills, no la sesión de un reto en curso.
CodeMirror 6
frontendEditor con resaltado de sintaxis real, no un <textarea> — necesario para que "el combate es código real" se sienta de verdad.
Vitest
toolingTestea la lógica de negocio (store, sandbox, codeRunner) sin levantar un Worker real ni mockear timers.
Playwright
toolingUn único e2e del flujo completo (título, mapa, reto real y resultados), deliberadamente el único test que toca el navegador.
GitHub Actions
toolingCI gratuito para un proyecto personal: typecheck, lint, Vitest y Playwright antes de cada merge.
pruébalo tú
Demo en vivo
La aplicación real, embebida aquí. No se carga hasta que la pides, para no penalizar la carga de esta página.