Proyectos
2026 Diseño y desarrollo completo Live

Space Clicker

Juego incremental con motor de TypeScript puro, balance garantizado por un test de CI y despliegue en el edge. Llega al millón de polvo estelar.

Rol
Diseño y desarrollo completo
Duración
2026
Año
2026
Estado
Live
TypeScript strict React 19 Vite Tailwind CSS v4 Vitest Zod Web Audio API Canvas Cloudflare Workers
Space Clicker — juego incremental de polvo estelar

El contexto

Mis otros proyectos demuestran tiempo real y edge computing; aquí quería demostrar lo contrario: ingeniería frontend pura, sin backend donde esconderse. Un clicker parece trivial, y esa es justo la trampa — debajo hay una economía exponencial que hay que balancear, estado mutando 10 veces por segundo que React no debe sufrir, persistencia que tiene que sobrevivir a versiones futuras del juego, y tres idiomas que mantener sincronizados.

El reto interesante no era hacer un clicker, sino hacerlo con estándares de producción: que el ritmo de juego esté garantizado por la CI, que un save corrupto jamás rompa una partida, y que el árbol de React no se entere de que hay un motor corriendo debajo. Todo con tres dependencias de runtime: react, react-dom y zod. Sin librería de estado, sin librería de i18n, sin un solo asset de audio.

Decisiones técnicas

1. Un motor de juego que no sabe que React existe

Toda la lógica vive en src/engine/ — TypeScript puro, cero imports de React. El motor avanza con timestep fijo de 100 ms usando el patrón acumulador sobre requestAnimationFrame: el render nunca dicta la velocidad de la simulación, y una pestaña congelada por el navegador colapsa en un único grant capado en lugar de seis horas de ticks encolados.

// El bucle: acumula tiempo real, consume pasos fijos
while (this.accumulatorMs >= TICK_MS) {
  this.tick(TICK_MS / 1000);
  this.accumulatorMs -= TICK_MS;
}
this.store.notifyIfDirty(); // ≤1 snapshot inmutable por frame

React se conecta como un suscriptor más, vía useSyncExternalStore con selectores hechos a mano:

// La fila de la tienda despierta cuando cambia SU dato, no a 10 Hz
const affordable = useGameSelector((s) => s.stardust >= price);

El resultado: el contador de polvo estelar re-renderiza a tick rate, y el resto del árbol solo cuando su valor seleccionado cambia. Audio, partículas y toasts ni siquiera pasan por React — se cuelgan de un emitter de eventos tipado del motor.

2. El balance del juego es un test de CI

La decisión de diseño más importante era el ritmo: llegar al millón tiene que costar 20-30 minutos de juego activo. En vez de tunear a ojo, escribí una simulación en Vitest donde un bot juega contra la clase Game real — ticks reales, compras reales, victoria real — clicando a ritmo humano y puntuando cada compra posible:

// tiempo-hasta-poder-pagarlo + amortización: sabe AHORRAR para el Dyson
score = max(0, (coste - banco) / ingresos) + coste / ΔSPS;

El test afirma que la victoria cae entre 15 y 35 minutos a 4.0/4.5/5.0 clics/s (resultado actual: 22,7/22,2/21,8 min), que la primera compra llega antes de 30 segundos y que no hay huecos muertos de más de 90 segundos en los primeros 10 minutos. Si mañana toco un precio y rompo el pacing, el build se pone rojo. El balance dejó de ser una opinión para ser un invariante ejecutable.

3. Saves que no se pueden romper

La partida se guarda en localStorage como JSON versionado y validado con Zod en cada carga:

JSON.parse → leer version → cadena de MIGRATIONS → safeParse → hidratar

Tres detalles que marcan la diferencia: los ids desconocidos se filtran al hidratar (renombrar contenido nunca brickea un save viejo), cualquier fallo copia el string crudo a una clave de backup antes de empezar de cero (nunca se borra data silenciosamente), y el export/import por código base64 reutiliza exactamente el mismo pipeline de validación. Cambiar el formato en el futuro = subir SAVE_VERSION y añadir una entrada { from, migrate }.

4. i18n donde el typechecker es el test

Español, inglés y francés sin librería: en.ts define el espacio de claves y los otros diccionarios se declaran como Record<keyof typeof en, string>. Borrar una traducción es un error de compilación, no una sorpresa en runtime. Un test de runtime cubre lo único que los tipos no pueden: que los placeholders {token} coincidan entre idiomas. Los números se formatean por locale con Intl.NumberFormat memoizado — 12.500 en español, 12,500 en inglés, 12 500 en francés.

Lo que aprendí

  • Separar motor y UI paga sola. Los 82 tests corren en Node puro, sin jsdom, porque el motor no tiene DOM. Con reloj y storage inyectables (new Game({ now, storage })), hasta el logro secreto de “jugar a las 3 AM” se testea inyectando las 3 AM.
  • Los tests unitarios no lo ven todo. Probando en navegador encontré que el logro “maestro zen” (5 minutos sin clicar) se regalaba al cargar cualquier partida con historial — la semántica de “inactividad” no sobrevivía al reload. Quedó arreglado con su test de regresión; el bug más interesante del proyecto salió de jugar, no de testear.
  • Accesibilidad en un juego también existe: el planeta es un <button> real (Espacio/Enter minan, el auto-repeat del teclado se rechaza), los logros se anuncian por aria-live, los modales son <dialog> nativo y el starfield respeta prefers-reduced-motion.
  • El edge también sirve para lo estático: el juego se despliega como Worker de solo assets con dominio custom (space-clicker.dyorch.com) — DNS y TLS aprovisionados en el primer wrangler deploy, cero servidores que mantener.

Ficha técnica

Tests82 en 9 suites (economía, motor, saves, logros, i18n, simulación de balance)
Bundle98 KB gzip — runtime solo react, react-dom, zod
Contenido7 generadores · 7 mejoras de clic · 27 logros (4 secretos)
IdiomasES / EN / FR con detección automática
SonidoSintetizado con Web Audio API — cero archivos de audio
OfflineGanancias pasivas capadas a 8 h con modal de bienvenida
CIGitHub Actions: typecheck + tests + build en cada push

Más capturas

Space Clicker — juego incremental de polvo estelar
Space Clicker — juego incremental de polvo estelar