Wiki · Devlog

Diario del desarrollador

Esta sección es para quienes tienen curiosidad sobre cómo se construyó CONTRABAND. Es un proyecto en solitario desarrollado durante varios meses usando únicamente JavaScript vanilla, CSS y HTML — sin motor de juego, sin frameworks pesados. El juego entero corre en una pestaña del navegador. Esta serie de notas documenta las decisiones arquitectónicas, los compromisos creativos y lo que aprendí en el camino.

¿Por qué no usar un motor?

La elección obvia para un juego de navegador habría sido Phaser, Three.js en bruto o un export HTML5 de Godot. Elegí vanilla por cuatro razones concretas:

  1. Tamaño de archivo. El juego completo pesa menos de 120KB de código. Incluso con los assets, el navegador lo carga instantáneamente. Un build WebGL de Unity ocuparía 20-50MB.
  2. Velocidad de arranque. De URL a jugable en menos de 2 segundos en un móvil moderno. Sin calentamiento del motor.
  3. Arquitectura modular. Cada sistema es su propio archivo con fronteras limpias. El combate no sabe de historias. Las historias no saben de combate. Se comunican vía EventBus.
  4. Cero lock-in de vendor. Si un framework muere, no pierdo nada. Los estándares web no rompen.

Arquitectura

El juego tiene cinco capas conceptuales organizadas por responsabilidad y nivel de dependencia:

Total: aproximadamente 10.000 líneas repartidas en 50+ archivos.

Decisiones de diseño clave

EventBus sobre acoplamiento directo

Cada sistema emite eventos (combat:victory, story:decision, galaxy:jump) en lugar de llamar directamente a otros sistemas. Esto significa que agregar una nueva funcionalidad — como Analytics — es simplemente "escuchar los eventos existentes". Cero refactor del resto del código. Cuando añadimos el sistema de monetización a mitad del desarrollo, no tuvimos que tocar combate ni historia: solo escuchar story:scene-unlocked para verificar si era un epilogue pagado.

State como única fuente de verdad

Hay una sola instancia GameState. Todo lee de ella. Guardar es literalmente JSON.stringify(state). Cargar es JSON.parse + Object.assign. Sin reducers complejos, sin middleware. Esto hizo que el sistema de guardado cross-device con Firestore fuera trivial: el payload subido a la nube es el mismo JSON que se guarda en localStorage.

Audio procedural

No licencié música. El soundtrack ambiental son seis ondas sinusoidales superpuestas con modulación LFO, generadas en tiempo de ejecución por la Web Audio API. Cuesta 0 de bandwidth y 0 dólares. Los efectos de disparo, explosiones, warp y UI también son procedurales: un oscilador + envelope + filtro, configurados por preset. El archivo de audio pesa exactamente 0 bytes.

Retratos ASCII

Los retratos de los personajes son arte ASCII, no imágenes. Cada NPC tiene un patrón de 4 líneas renderizado en el color dorado de acento. Fue una decisión creativa que encaja con la estética retro-terminal del juego, pero también es práctica: cero assets de imagen, cero tiempo de carga.

Firestore como autoridad, localStorage como caché

Los entitlements de compras (los epilogues pagos, los cosméticos) se guardan en Firestore vía webhooks de Stripe. Pero no consultamos Firestore en cada render: al iniciar, sincronizamos una vez y cacheamos en localStorage. Esto significa que el juego arranca sin depender de la red, y aun así las compras se reflejan correctamente entre dispositivos.

Integración con Stripe y Firebase

La monetización usa funciones serverless de Vercel: checkout.js crea una sesión Stripe, stripe-webhook.js escucha eventos y escribe entitlements a Firestore, verify-purchase.js valida antes de desbloquear contenido, y link-purchase.js vincula compras guest a cuentas autenticadas. Todo el flujo ocupa menos de 300 líneas de Node.js.

Qué haría diferente

Si empezara de cero, cambiaría algunas decisiones:

Estadísticas de desarrollo

Números aproximados:

Construido en Irving, Texas. Alimentado por demasiado café y demasiada música ambient.

Posts individuales del devlog