Reestructuración de un juego multijugador: Monorepo, servicios duales, capa de lógica compartida y principio abierto‑cerrado

El mayor problema antes de la reestructuración era la duplicación de lógica: la detección de colisiones, el cálculo de velocidad y las fórmulas de daño estaban tanto en el servicio de sala (room‑service) como en el cliente (new_basic2). Con el tiempo, ambas versiones inevitablemente se desincronizan.

Solución: extraer toda la lógica pura en un paquete independiente packages/logic/.

       paquete logic (funciones puras)
      /              \
 room‑service      frontend
 (Node.js)         (navegador)

Restricciones clave:

  • Funciones puras: entrada → salida, sin efectos secundarios.
  • Sin IO: no toca red, archivos ni DOM.
  • Sin estado: el estado global se gestiona externamente; la lógica solo ve los datos del frame actual.

¿Por qué ReScript? ReScript (antes BuckleScript/ReasonML) es inmutable por defecto, tiene pattern matching elegante y produce un bundle muy pequeño (~49KB, gzip ~15KB). La anotación @genType genera definiciones de tipo .gen.tsx que TypeScript puede importar directamente.

/* Movimiento.res – Lógica pura escrita en ReScript */
let calcularVelocidad = (tipoPersonaje: string): float => {
  if tipoPersonaje === "gigante" { 2.5 } else { 1.0 }
}

let ejecutarComando = (estado: estadoJuego, comando: comando): estadoJuego => {
  switch comando.tipo {
  | Mover => { ...estado, posicion: calcularNuevaPosicion(estado, comando) }
  | Atacar => { ...estado, hp: estado.hp -. calcularDanio(estado, comando) }
  }
}

Funciones principales del paquete logic:

  • ejecutarComando – procesa comandos de movimiento/ataque.
  • detectarColision – comprueba colisión entre dos entidades.
  • calcularVelocidad – velocidad según tipo de personaje.
  • calcularDanioColision – daño por colisión.
  • obtenerCajaColision – caja de colisión del modelo.
  • obtenerHpMaximo – vida máxima del personaje.

Estas funciones se ejecutan en el servidor como fuente de verdad y en el cliente como predicción local, usando exactamente el mismo código. Así se elimina la inconsistencia entre servidor y cliente.

Por qué la programación funcional encaja en la lógica compartida multijugador

El paquete logic/ está escrito en ReScript (lenguaje funcional) porque la lógica compartida en un juego multijugador se beneficia enormemente del paradigma funcional. La orientación a objetos suele ser contraproducente.

El problema central: el mismo código debe ejecutarse en dos runtimes distintos (Node + navegador) y producir resultados idénticos.

Función pura

/* ✅ Pura: entrada → salida, sin efectos secundarios */
function detectarColision(a: Jugador, b: Jugador): boolean {
  const dx = a.x - b.x;
  const dz = a.z - b.z;
  return Math.sqrt(dx * dx + dz * dz) < 1.0;
}

/* ❌ OOP: el método depende del estado interno del objeto */
class SistemaColision {
  private jugadores: Jugador[];
  chequearColision(a: Jugador, b: Jugador): boolean {
    // this.jugadores podría cambiar entre llamadas
  }
}

Ventaja: con la misma entrada (coordenadas de dos jugadores) se obtiene siempre la misma salida. No depende de this, ni de variables globales, ni del tiempo – justo lo que evita los problemas de sincronización por frames.

Aceptar y devolver estado

En OOP el estado se modifica internamente con this.state = nuevoEstado, lo que dificulta la sincronización. En FP se pasa el estado como argumento y se devuelve uno nuevo.

/* ✅ FP: recibe estado, devuelve nuevo estado */
function ejecutarComando(estado: EstadoJuego, cmd: Comando): EstadoJuego {
  if (cmd.tipo === 'Mover') {
    return { ...estado, posicion: calcularNuevaPosicion(estado, cmd) };
  } else if (cmd.tipo === 'Atacar') {
    return { ...estado, hp: estado.hp - calcularDanio(estado, cmd) };
  }
  return estado;
}

// El llamante gestiona el flujo
const nuevoEstado = ejecutarComando(estadoViejo, comandoMov);
difundirEstado(nuevoEstado);

/* ❌ OOP: modifica directamente el estado interno */
class Juego {
  private estado: EstadoJuego;
  ejecutarComando(cmd: Comando) {
    if (cmd.tipo === 'Mover') {
      this.estado.posicion = calcularNuevaPosicion(cmd); // mutación
    }
  }
}

El enfoque funcional permite que quien invoca decida si difundir, persistir o tomar una instantánea del estado. En cada frame el servidor puede empaquetar el estado y enviarlo a todos los clientes, porque el estado es un dato plano, no una propiedad privada de un objeto.

Inmutabilidad

Tras viajar por la red, el estado es un objeto JSON «aplanado». Si un jugador muta el estado localmente, otros recibirían datos sucios. La inmutabilidad evita esto: cada cambio crea un nuevo objeto, y el estado anterior se conserva como instantánea histórica. Esto simplifica el rollback, la reproducción y la reconexión.

/* ✅ Inmutable: crea nuevo estado con spread */
{ ...estado, posicion: nuevaPos }

/* ❌ Mutable: modifica directamente, rompe otras referencias */
estado.posicion = nuevaPos; // ¡otras partes aún usan el viejo!

Composición de funciones

La lógica multijugador es un pipeline: recibir comandos → ejecutar → detectar colisiones → aplicar daño → difundir. La composición funcional lo expresa de forma natural:

/* ✅ Composición: pipeline visible */
function tick(estado: EstadoJuego, comandos: Comando[]): EstadoJuego {
  return comandos
    .reduce(ejecutarComando, estado)
    .let(detectarColisiones)
    .let(aplicarDanio)
    .let(limpiarJugadoresMuertos);
}

Con OOP el mismo flujo se dispersaría en varios métodos de managers, conectados con eventos y callbacks, dificultando el seguimiento.

Currificación

La currificación facilita la configuración de funciones:

// Currificación: primero fijamos el tipo de personaje
function calcularVelocidad(tipo: string): (distancia: number) => number {
  const base = tipo === 'gigante' ? 2.5 : 1.0;
  return (distancia) => base * distancia;
}

const velGigante = calcularVelocidad('gigante');
const velPequeño = calcularVelocidad('pequeño');

velGigante(0.5); // 1.25
velPequeño(0.5); // 0.5

En OOP esto requeriría un patrón Strategy o Factory, con clases e interfaces, aumentando la complejidad.

Comparativa FP vs OOP en lógica compartida

Dimensión FP OOP Impacto en multijugador
Gestión de estado Input estado → Output estado Métodos modifican estado interno En FP, el estado se serializa y difunde directamente
Efectos secundarios Función pura, sin tocar externo Método depende de this/atributos FP garantiza idéntico resultado en ambos runtimes
Testabilidad Entrada → salida, sin mocks Requiere mocks y DI Tests FP requieren 3‑5 veces menos código
Seguridad concurrente Inmutabilidad natural Bloqueos + mutex Alta concurrencia en servidor beneficia a FP
Serialización entre runtimes Datos JSON/Record planos Objetos con referencias circulares Estado FP se puede JSON.stringify directamente

En la práctica, la diferencia se nota en los tests. Para probar ejecutarComando en FP:

// Test FP: crear entrada → llamar función → comprobar salida
test('comando de movimiento actualiza posición', () => {
  const estado = crearEstadoInicial();
  const cmd = { tipo: 'Mover', x: 10, z: 10 };
  const nuevoEstado = ejecutarComando(estado, cmd);
  expect(nuevoEstado.posicion.x).toBe(10);
});

Para probar el equivalente en OOP haría falta instanciar un objeto, mockear base de datos, mockear red, llamar al método y luego leer el estado interno para verificar.

Efecto dominó: hacia multihilo, WebGPU y SoA

La decisión funcional no solo afecta a la lógica compartida. Abre la puerta a:

  • Multihilo (Workers): cada worker recibe una instantánea del estado, la procesa y devuelve un nuevo estado. Sin locks, sin condiciones de carrera. Con OOP habría que bloquear el objeto compartido, perdiendo rendimiento.
  • WebGPU Compute Shader: los shaders son esencialmente funciones puras (buffer de entrada → buffer de salida). Si la lógica ya es funcional, migrar a GPU es natural: jugadores.map(actualizarUno)dispatchWorkgroups(bufferPosiciones).
  • SoA (Structure of Arrays): la inmutabilidad encaja con SoA porque los datos son valores planos, no objetos con métodos. Se pueden usar Float32Array para posiciones, velocdiades, HP, mejorando localidad de caché, eliminando GC y preparando los datos para GPU.

En esta etapa (P8) nada de esto se implementó, pero la decisión funcional lo hace posible en el futuro. Si se hubiera elegido OOP con estado mutable, cualquiera de esas migraciones requeriría reescribir por completo.

Arquitectura de dos servicios

Desde la fase basic1 el juego ya usaba dos servicios separados. En el directorio demos/backend/ convivían:

  • Room‑service: gestiona el bucle de juego, sincronización de estado, detección de colisiones (WebSocket, puerto 4003).
  • Match‑service: gestiona creación de salas, emparejamiento, listado (HTTP, puerto 3000).

La separación temprana se debió a que los ciclos de vida y protocolos son diferentes: el servicio de juego necesita una conexión larga (WebSocket) para enviar estado en tiempo real, mientras que el de emparejamiento solo requiere peticiones‑respuesta HTTP. Mezclarlos haría que cambios en el emparejamiento afectaran al juego, y que las conexinoes WebSocket compitieran con las consultas HTTP.

La reestructuración consistió en extraer esas responsabilidades a paquetes independientes:

# Antes
demos/backend/ (lógica de sala y emparejamiento mezcladas en Main.ts)

# Después
packages/room-service/ (WebSocket, 4003)
packages/match-service/ (HTTP, 3000)

Flujo de comunicación:

Navegador
  ├── WS → packages/room-service (estado de juego en tiempo real)
  └── HTTP → packages/match-service (crear/buscar sala)
               │
           WS escucha estado de room‑service

La seguridad de tipos en toda la cadena se mantiene gracias al serviceProto.ts de TSRPC: si se cambia un archivo de protocolo, todos los lugares que lo usan fallan en compilación, detectando problemas antes del despliegue.

Monorepo: extensión de la estructura existente

El proyecto GTS‑Play ya era un monorepo con Lerna desde la versión para un solo jugador: varios paquetes (front end, mods, defaults, asset‑lib…) en el mismo repositorio, gestionados con yarn workspaces. La incorporación de la funcionalidad multijugador no requirió una nueva arquitectura, sino agregar nuevos paquetes al monorepo existente.

Antes, los directorios demos/basic1/ y demos/new_basic2/ usaban referencias ../../../ para importar tipos, que se rompían al cambiar la estructura. Al moverlos a packages/:

// Antes
import { MsgGameState } from "../../../packages/room-service/src/...";

// Después
import { MsgGameState } from "room-service/src/shared/protocols/MsgGameState";

Gracias a yarn workspaces, packages/room-service/ se enlaza automáticamente en node_modules/room-service/.

packages/
├── frontend/          ← versión single‑player (sin cambios)
├── room-service/      ← servidor de juego (WebSocket, 4003)
├── match-service/     ← servidor de emparejamiento (HTTP, 3000)
└── logic/             ← lógica compartida (ReScript, funciones puras)

Problema resuelto: dependencias circulares. Al inicio, algunos paquetes se referenciaban mutuamente formando ciclos. La solución fue dibujar el grafo de dependencias y forzar una cadena unidireccional:

logic (base, sin dependencias)
   ↑
room‑service (depende de logic)
   ↑
frontend (depende de tipos de room‑service y de logic)
   ↑
match‑service (capa superior, depende del estado de las salas de room‑service)

Principio abierto‑cerrado: el modo single‑player intacto

Una restricción importante: la versión comercial del juego single‑player «Giganta jugando» no debía modificarse por la funcionalidad multijugador. Se aplicó el principio abierto‑cerrado: abierto a extensión, cerrado a modificación.

frontend/src/
├── scene3d_layer/               ← código single‑player (¡sin cambios!)
├── business_layer/
│   └── multiplayer/             ← solo código nuevo para multijugador
├── render_layer/
│   ├── Render.ts                ← render single‑player
│   └── MultiplayerRender.ts     ← render multijugador (nuevo)
├── render_interface/            ← capa de abstracción IRenderer
└── logic_layer/                 ← lógica compartida

Regla fundamental: ninguna línea de los archivos single‑player fue modificada. Todo el código multijugador se añadió en business_layer/multiplayer/. El estado multijugador se guarda en el subcampo state.multiplayer; el código single‑player ignora ese campo, por lo que no hay conflictos.

Al salir de una partida multijugador, el método dispose a nivel de módulo se encarga de limpiar todos los recursos: clearInterval, conexiones WebSocket, renderizador multijugador.

Esta restricción es especialmente importante en la era de la colaboración con IA. En el desarrollo tradicional, el principio abierto‑cerrado es una buena práctica; con IA es una necesidad, porque la IA no sabe qué archivos no debe tocar. Al aislar el código nuevo en un directorio separado, la IA solo puede escribir en business_layer/multiplayer/ y nunca modificará el código single‑player.

Resumen de decisiones

  • Capa de lógica compartida (logic): resuelve la inconsistencia servidor‑cliente. ReScript, funciones puras, 49KB.
  • Separación en dos servicios: clarifica responsabilidades. Comunicación WS + HTTP, extraído de demos/backend.
  • Extensión del monorepo: añadir paquetes a la estructura Lerna + yarn workspaces existente.
  • Principio abierto‑cerrado: convivencia single‑player y multijugador. Directorio aislado + método dispose.

La reestructuración del 9 de junio de 2026 sentó las bases arquitectónicas para todas las iteraciones siguientes. Durante las semanas posteriores se añadieron funcionalidades sobre este armazón sin volver a tocar la arquitectura base.

Etiquetas: Monorepo ReScript Functions Puras WebSocket Arquitectura de Servicios

Publicado el 8-28 23:21