cd ../proyectos codequest-rpg

CodeQuest RPG

operativo

RPG 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.

Educación
fases6retos de código11bundle inicial−70 % de JS en la carga inicial · code-splitting de CodeMirrorcomprobando…

hop 01 · por qué existe

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.

audit — antes/después

salvar · lo que ya funcionaba

  • La ambientación
  • El mapa de zonas

tirar · lo que hacía que no funcionara

  • La mecánica de trivia de opción múltiple
  • El estado disperso entre componentes
  • Los archivos sin tipar ni testear

hop 02 · el porqué, no solo el qué

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>

hop 03 · lo más difícil

Reto técnico

eval() en el hilo principal

Mismo scope que el resto de la app: un bucle infinito bloquea la UI sin retorno.

en espera

worker aislado + terminate()

Hilo real y aislado; el principal puede matarlo desde fuera si se cuelga.

en espera

simulado — no se ejecuta código arbitrario en esta página

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.

src/lib/codeRunner.ts
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);
};

hop 04 · cómo llegó hasta aquí

Fases de desarrollo

fase 1 de 6

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

hop 05 · dónde está hoy

Resultado

734 kB

antes

216 kB

después

hop 06 · con qué está construido

Stack

ls -la — stack/

React 19

frontend

Rendimiento y estabilidad para una SPA con pantallas que se montan y desmontan mucho (mapa ↔ reto).

TypeScript (strict)

tooling

Migración progresiva desde JS sin tipos, archivo por archivo; strict detecta los mismos bugs de estado que antes solo aparecían jugando.

Vite

tooling

Arranque y HMR instantáneos, clave para iterar rápido en un proyecto que se reconstruyó por fases incrementales.

Zustand + persist

frontend

Un ú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

frontend

Editor con resaltado de sintaxis real, no un <textarea> — necesario para que "el combate es código real" se sienta de verdad.

Vitest

tooling

Testea la lógica de negocio (store, sandbox, codeRunner) sin levantar un Worker real ni mockear timers.

Playwright

tooling

Un único e2e del flujo completo (título, mapa, reto real y resultados), deliberadamente el único test que toca el navegador.

GitHub Actions

tooling

CI gratuito para un proyecto personal: typecheck, lint, Vitest y Playwright antes de cada merge.

pruébalo tú

Demo en vivo

codequest.pablo-redondo.devAbrir en pestaña

La aplicación real, embebida aquí. No se carga hasta que la pides, para no penalizar la carga de esta página.