Cómo construir un juego multiusuario en tiempo real desde cero

Publicado: 2022-03-10
Resumen rápido ↬ Este artículo destaca el proceso, las decisiones técnicas y las lecciones aprendidas detrás de la construcción del juego en tiempo real Autowuzzler. Aprenda a compartir el estado del juego entre varios clientes en tiempo real con Colyseus, haga cálculos de física con Matter.js, almacene datos en Supabase.io y cree la interfaz con SvelteKit.

A medida que la pandemia persistía, el equipo con el que trabajo, repentinamente remoto, se vio cada vez más privado de futbolín. Pensé en cómo jugar al futbolín en un entorno remoto, pero estaba claro que simplemente reconstruir las reglas del futbolín en una pantalla no sería muy divertido.

Lo que es divertido es patear una pelota con carritos de juguete, algo que me di cuenta mientras jugaba con mi hijo de 2 años. Esa misma noche me dispuse a construir el primer prototipo de un juego que se convertiría en Autowuzzler .

La idea es simple : los jugadores conducen autos de juguete virtuales en una arena de arriba hacia abajo que se asemeja a una mesa de futbolín. El primer equipo en marcar 10 goles gana.

Por supuesto, la idea de usar autos para jugar al fútbol no es única, pero dos ideas principales deberían diferenciar a Autowuzzler : quería reconstruir algo de la apariencia de jugar en una mesa de futbolín física, y quería asegurarme de que fuera lo más fácil posible para invitar a amigos o compañeros de equipo a un juego casual rápido.

En este artículo, describiré el proceso detrás de la creación de Autowuzzler , qué herramientas y marcos elegí, y compartiré algunos detalles de implementación y lecciones que aprendí.

Interfaz de usuario del juego que muestra el fondo de una mesa de futbolín, seis autos en dos equipos y una pelota.
Autowuzzler (beta) con seis jugadores simultáneos en dos equipos. (Vista previa grande)

Primer prototipo funcional (terrible)

El primer prototipo se construyó utilizando el motor de juegos de código abierto Phaser.js, principalmente por el motor de física incluido y porque ya tenía algo de experiencia con él. El escenario del juego se incrustó en una aplicación Next.js, nuevamente porque ya tenía una sólida comprensión de Next.js y quería concentrarme principalmente en el juego.

Como el juego debe ser compatible con varios jugadores en tiempo real , utilicé Express como intermediario de WebSockets. Sin embargo, aquí es donde se vuelve complicado.

Dado que los cálculos físicos se realizaron en el cliente en el juego Phaser, elegí una lógica simple, pero obviamente defectuosa: el primer cliente conectado tenía el dudoso privilegio de realizar los cálculos físicos para todos los objetos del juego y enviar los resultados al servidor express. que a su vez transmitía las posiciones, ángulos y fuerzas actualizados a los clientes del otro jugador. Luego, los otros clientes aplicarían los cambios a los objetos del juego.

Esto condujo a la situación en la que el primer jugador pudo ver la física que sucedía en tiempo real (después de todo, está sucediendo localmente en su navegador), mientras que todos los demás jugadores se retrasaron al menos 30 milisegundos (la tasa de transmisión que elegí ) o, si la conexión de red del primer jugador era lenta, considerablemente peor.

Si esto le parece una mala arquitectura, tiene toda la razón. Sin embargo, acepté este hecho a favor de obtener rápidamente algo jugable para averiguar si el juego es realmente divertido .

Validar la idea, desechar el prototipo

A pesar de lo defectuosa que era la implementación, era lo suficientemente jugable como para invitar a amigos a una primera prueba de manejo. Los comentarios fueron muy positivos , siendo la principal preocupación, como era de esperar, el rendimiento en tiempo real. Otros problemas inherentes incluyeron la situación en la que el primer jugador (recuerde, el que estaba a cargo de todo ) dejó el juego, ¿quién debería hacerse cargo? En este punto, solo había una sala de juegos, por lo que cualquiera se uniría al mismo juego. También estaba un poco preocupado por el tamaño del paquete que introdujo la biblioteca Phaser.js.

Era hora de deshacerse del prototipo y comenzar con una nueva configuración y un objetivo claro.

Configuración del proyecto

Claramente, el enfoque de "el primer cliente gobierna todo" necesitaba ser reemplazado por una solución en la que el estado del juego vive en el servidor . En mi investigación, me encontré con Colyseus, que sonaba como la herramienta perfecta para el trabajo.

Para los otros bloques de construcción principales del juego, elegí:

  • Matter.js como motor de física en lugar de Phaser.js porque se ejecuta en Node y Autowuzzler no requiere un marco de juego completo.
  • SvelteKit como marco de aplicación en lugar de Next.js, porque en ese momento entró en versión beta pública. (Además: me encanta trabajar con Svelte).
  • Supabase.io para almacenar PIN de juegos creados por el usuario.

Veamos esos bloques de construcción con más detalle.

¡Más después del salto! Continúe leyendo a continuación ↓

Estado de juego sincronizado y centralizado con Colyseus

Colyseus es un marco de juego multijugador basado en Node.js y Express. En esencia, proporciona:

  • Sincronizar el estado de los clientes de manera autorizada;
  • Comunicación eficiente en tiempo real usando WebSockets enviando solo datos modificados;
  • Configuraciones de varias habitaciones;
  • Bibliotecas cliente para JavaScript, Unity, Defold Engine, Haxe, Cocos Creator, Construct3;
  • Enlaces de ciclo de vida, por ejemplo, se crea una sala, se une un usuario, se va un usuario y más;
  • Enviar mensajes, ya sea como mensajes de difusión a todos los usuarios de la sala o a un solo usuario;
  • Un panel de monitoreo incorporado y una herramienta de prueba de carga.

Nota : Los documentos de Colyseus facilitan el inicio con un servidor básico de Colyseus al proporcionar un npm init y un repositorio de ejemplos.

Crear un esquema

La entidad principal de una aplicación de Colyseus es la sala de juegos, que contiene el estado de una sola instancia de sala y todos sus objetos de juego. En el caso de Autowuzzler , es una sesión de juego con:

  • dos equipos,
  • una cantidad finita de jugadores,
  • una bola.

Es necesario definir un esquema para todas las propiedades de los objetos del juego que deben sincronizarse entre los clientes . Por ejemplo, queremos que la pelota se sincronice, por lo que necesitamos crear un esquema para la pelota:

 class Ball extends Schema { constructor() { super(); this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; } } defineTypes(Ball, { x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number" });

En el ejemplo anterior, se crea una nueva clase que amplía la clase de esquema proporcionada por Colyseus; en el constructor, todas las propiedades reciben un valor inicial. La posición y el movimiento de la pelota se describen usando las cinco propiedades: x , y , angle , velocityX, velocityY . Además, necesitamos especificar los tipos de cada propiedad . Este ejemplo usa la sintaxis de JavaScript, pero también puede usar la sintaxis de TypeScript un poco más compacta.

Los tipos de propiedad pueden ser tipos primitivos:

  • string
  • boolean
  • number (así como tipos enteros y flotantes más eficientes)

o tipos complejos:

  • ArraySchema (similar a Array en JavaScript)
  • MapSchema (similar a Map en JavaScript)
  • SetSchema (similar a Establecer en JavaScript)
  • CollectionSchema (similar a ArraySchema, pero sin control sobre los índices)

La clase Ball anterior tiene cinco propiedades de tipo number : sus coordenadas ( x , y ), su angle actual y el vector de velocidad ( velocityX , velocityY ).

El esquema para los jugadores es similar, pero incluye algunas propiedades más para almacenar el nombre del jugador y el número del equipo, que deben proporcionarse al crear una instancia de jugador:

 class Player extends Schema { constructor(teamNumber) { super(); this.name = ""; this.x = 0; this.y = 0; this.angle = 0; this.velocityX = 0; this.velocityY = 0; this.teamNumber = teamNumber; } } defineTypes(Player, { name: "string", x: "number", y: "number", angle: "number", velocityX: "number", velocityY: "number", angularVelocity: "number", teamNumber: "number", });

Finalmente, el esquema para Autowuzzler Room conecta las clases definidas previamente: una instancia de sala tiene varios equipos (almacenados en un ArraySchema). También contiene una sola pelota, por lo que creamos una nueva instancia de Ball en el constructor de RoomSchema. Los jugadores se almacenan en un MapSchema para una recuperación rápida utilizando sus ID.

 class RoomSchema extends Schema { constructor() { super(); this.teams = new ArraySchema(); this.ball = new Ball(); this.players = new MapSchema(); } } defineTypes(RoomSchema, { teams: [Team], // an Array of Team ball: Ball, // a single Ball instance players: { map: Player } // a Map of Players });
Nota : se omite la definición de la clase Team .

Configuración de varias habitaciones ("Match-Making")

Cualquiera puede unirse a un juego de Autowuzzler si tiene un PIN de juego válido. Nuestro servidor Colyseus crea una nueva instancia de Sala para cada sesión de juego tan pronto como el primer jugador se une y descarta la sala cuando el último jugador la abandona.

El proceso de asignación de jugadores a su sala de juego deseada se denomina "creación de coincidencias". Colyseus hace que sea muy fácil de configurar utilizando el método filterBy al definir una nueva habitación:

 gameServer.define("autowuzzler", AutowuzzlerRoom).filterBy(['gamePIN']);

Ahora, cualquier jugador que se una al juego con el mismo gamePIN de juego (veremos cómo "unirse" más adelante) terminará en la misma sala de juego. Cualquier actualización de estado y otros mensajes de difusión se limitan a los jugadores en la misma sala.

Física en una aplicación Colyseus

Colyseus proporciona mucho listo para usar para comenzar a funcionar rápidamente con un servidor de juegos autorizado, pero deja que el desarrollador cree la mecánica real del juego, incluida la física. Phaser.js, que utilicé en el prototipo, no se puede ejecutar en un entorno sin navegador, pero el motor de física integrado de Phaser.js, Matter.js, se puede ejecutar en Node.js.

Con Matter.js, defines un mundo físico con ciertas propiedades físicas como su tamaño y gravedad. Proporciona varios métodos para crear objetos físicos primitivos que interactúan entre sí al adherirse a las leyes (simuladas) de la física, que incluyen masa, colisiones, movimiento con fricción, etc. Puede mover objetos aplicando fuerza , tal como lo haría en el mundo real.

Un "mundo" Matter.js se encuentra en el corazón del juego Autowuzzler ; define qué tan rápido se mueven los autos, qué tan rebotada debe ser la pelota, dónde se ubican los goles y qué sucede si alguien marca un gol.

 let ball = Bodies.circle( ballInitialXPosition, ballInitialYPosition, radius, { render: { sprite: { texture: '/assets/ball.png', } }, friction: 0.002, restitution: 0.8 } ); World.add(this.engine.world, [ball]);

Código simplificado para agregar un objeto de juego de "pelota" al escenario en Matter.js.

Una vez que se definen las reglas, Matter.js puede ejecutarse con o sin renderizar algo en una pantalla. Para Autowuzzler , utilizo esta función para reutilizar el código del mundo físico tanto para el servidor como para el cliente, con varias diferencias clave:

Mundo de la física en el servidor :

  • recibe la entrada del usuario (eventos de teclado para conducir un automóvil) a través de Colyseus y aplica la fuerza adecuada sobre el objeto del juego (el automóvil del usuario);
  • hace todos los cálculos físicos para todos los objetos (jugadores y la pelota), incluida la detección de colisiones;
  • comunica el estado actualizado de cada objeto del juego a Colyseus, que a su vez lo transmite a los clientes;
  • se actualiza cada 16,6 milisegundos (= 60 fotogramas por segundo), activado por nuestro servidor Colyseus.

El mundo de la física en el cliente :

  • no manipula los objetos del juego directamente;
  • recibe el estado actualizado de cada objeto del juego de Colyseus;
  • aplica cambios de posición, velocidad y ángulo después de recibir el estado actualizado;
  • envía la entrada del usuario (eventos de teclado para conducir un automóvil) a Colyseus;
  • carga los sprites del juego y usa un renderizador para dibujar el mundo de la física en un elemento de lienzo;
  • omite la detección de colisiones (usando la opción isSensor para objetos);
  • actualizaciones usando requestAnimationFrame, idealmente a 60 fps.
Diagrama que muestra dos bloques principales: Colyseus Server App y SvelteKit App. La aplicación Colyseus Server contiene el bloque Autowuzzler Room, la aplicación SvelteKit contiene el bloque Colyseus Client. Ambos bloques principales comparten un bloque llamado Physics World (Matter.js)
Unidades lógicas principales de la arquitectura Autowuzzler: Physics World se comparte entre el servidor Colyseus y la aplicación cliente SvelteKit. (Vista previa grande)

Ahora, con toda la magia sucediendo en el servidor, el cliente solo maneja la entrada y dibuja el estado que recibe del servidor en la pantalla. Con una excepcion:

Interpolación en el cliente

Dado que estamos reutilizando el mismo mundo físico de Matter.js en el cliente, podemos mejorar el rendimiento experimentado con un simple truco. En lugar de solo actualizar la posición de un objeto del juego, también sincronizamos la velocidad del objeto . De esta forma, el objeto sigue moviéndose en su trayectoria incluso si la próxima actualización del servidor tarda más de lo habitual. Entonces, en lugar de mover objetos en pasos discretos de la posición A a la posición B, cambiamos su posición y hacemos que se muevan en cierta dirección.

Ciclo vital

La clase Room Autowuzzler es donde se maneja la lógica relacionada con las diferentes fases de una sala Colyseus. Colyseus proporciona varios métodos de ciclo de vida:

  • onCreate : cuando se crea una nueva sala (generalmente cuando se conecta el primer cliente);
  • onAuth : como gancho de autorización para permitir o denegar la entrada a la habitación;
  • onJoin : cuando un cliente se conecta a la sala;
  • onLeave : cuando un cliente se desconecta de la habitación;
  • onDispose : cuando se descarta la habitación.

La sala Autowuzzler crea una nueva instancia del mundo de la física (consulte la sección "Física en una aplicación de Colyseus") tan pronto como se crea ( onCreate ) y agrega un jugador al mundo cuando un cliente se conecta ( onJoin ). A continuación, actualiza el mundo de la física 60 veces por segundo (cada 16,6 milisegundos) utilizando el método setSimulationInterval (nuestro ciclo principal del juego):

 // deltaTime is roughly 16.6 milliseconds this.setSimulationInterval((deltaTime) => this.world.updateWorld(deltaTime));

Los objetos de física son independientes de los objetos de Colyseus, lo que nos deja con dos permutaciones del mismo objeto de juego (como la pelota), es decir, un objeto en el mundo de la física y un objeto de Colyseus que se puede sincronizar.

Tan pronto como el objeto físico cambie, sus propiedades actualizadas deben volver a aplicarse al objeto Colyseus. Podemos lograrlo escuchando el evento afterUpdate de afterUpdate y configurando los valores desde allí:

 Events.on(this.engine, "afterUpdate", () => { // apply the x position of the physics ball object back to the colyseus ball object this.state.ball.x = this.physicsWorld.ball.position.x; // ... all other ball properties // loop over all physics players and apply their properties back to colyseus players objects })

Hay una copia más de los objetos que debemos cuidar: los objetos del juego en el juego de cara al usuario .

Diagrama que muestra las tres versiones de un objeto de juego: Colyseus Schema Objects, Matter.js Physics Objects, Client Matter.js Physics Objects. Matter.js actualiza la versión Colyseus del objeto, Colyseus se sincroniza con el objeto de física Matter.js del cliente.
Autowuzzler mantiene tres copias de cada objeto físico, una versión autorizada (objeto Colyseus), una versión en el mundo físico de Matter.js y una versión en el cliente. (Vista previa grande)

Aplicación del lado del cliente

Ahora que tenemos una aplicación en el servidor que maneja la sincronización del estado del juego para varias salas, así como los cálculos físicos, concentrémonos en construir el sitio web y la interfaz real del juego . La interfaz de Autowuzzler tiene las siguientes responsabilidades:

  • permite a los usuarios crear y compartir PIN de juegos para acceder a salas individuales;
  • envía los PIN del juego creados a una base de datos de Supabase para persistencia;
  • proporciona una página opcional "Únete a un juego" para que los jugadores ingresen el PIN del juego;
  • valida los PIN del juego cuando un jugador se une a un juego;
  • aloja y presenta el juego real en una URL compartible (es decir, única);
  • se conecta al servidor Colyseus y maneja las actualizaciones de estado;
  • proporciona una página de destino ("marketing").

Para la implementación de esas tareas, elegí SvelteKit sobre Next.js por las siguientes razones:

¿Por qué SvelteKit?

He querido desarrollar otra aplicación usando Svelte desde que construí neolightsout. Cuando SvelteKit (el marco de trabajo oficial de la aplicación para Svelte) pasó a la versión beta pública, decidí compilar Autowuzzler con él y aceptar cualquier dolor de cabeza que surja al usar una versión beta nueva; la alegría de usar Svelte claramente lo compensa.

Estas características clave me hicieron elegir SvelteKit sobre Next.js para la implementación real de la interfaz del juego:

  • Svelte es un marco de interfaz de usuario y un compilador y, por lo tanto, envía un código mínimo sin un tiempo de ejecución del cliente;
  • Svelte tiene un lenguaje de plantillas expresivo y un sistema de componentes (preferencia personal);
  • Svelte incluye tiendas globales, transiciones y animaciones listas para usar, lo que significa: no hay fatiga de decisión al elegir un conjunto de herramientas de administración de estado global y una biblioteca de animación;
  • Svelte admite CSS con ámbito en componentes de un solo archivo;
  • SvelteKit admite SSR, enrutamiento basado en archivos simple pero flexible y rutas del lado del servidor para construir una API;
  • SvelteKit permite que cada página ejecute código en el servidor, por ejemplo, para obtener datos que se utilizan para representar la página;
  • Diseños compartidos entre rutas;
  • SvelteKit se puede ejecutar en un entorno sin servidor.

Creación y almacenamiento de PIN de juegos

Antes de que un usuario pueda comenzar a jugar, primero debe crear un PIN de juego. Al compartir el PIN con otros, todos pueden acceder a la misma sala de juegos.

Captura de pantalla de la sección Iniciar un nuevo juego del sitio web de Autowuzzler que muestra el PIN del juego 751428 y las opciones para copiar y compartir el PIN y la URL del juego.
Comience un nuevo juego copiando el PIN del juego generado o comparta el enlace directo a la sala de juegos. (Vista previa grande)

Este es un gran caso de uso para puntos finales del lado del servidor SvelteKits junto con la función Sveltes onMount: el punto final /api/createcode genera un PIN del juego, lo almacena en una base de datos Supabase.io y genera el PIN del juego como respuesta . Esta respuesta se obtiene tan pronto como se monta el componente de página de la página "crear":

Diagrama que muestra tres secciones: Crear página, punto final de código de creación y Supabase.io. La página de creación obtiene el punto final en su función onMount, el punto final genera un PIN del juego, lo almacena en Supabase.io y responde con el PIN del juego. La página Crear luego muestra el PIN del juego.
Los PIN del juego se crean en el punto final, se almacenan en una base de datos de Supabase.io y se muestran en la página "Crear". (Vista previa grande)

Almacenamiento de PIN de juegos con Supabase.io

Supabase.io es una alternativa de código abierto a Firebase. Supabase hace que sea muy fácil crear una base de datos PostgreSQL y acceder a ella a través de una de sus bibliotecas cliente o mediante REST.

Para el cliente JavaScript, importamos la función createClient y la ejecutamos usando los parámetros supabase_url y supabase_key que recibimos al crear la base de datos. Para almacenar el PIN del juego que se crea en cada llamada al punto final createcode , todo lo que tenemos que hacer es ejecutar esta simple consulta de insert :

 import { createClient } from '@supabase/supabase-js' const database = createClient( import.meta.env.VITE_SUPABASE_URL, import.meta.env.VITE_SUPABASE_KEY ); const { data, error } = await database .from("games") .insert([{ code: 123456 }]);

Nota : supabase_url y supabase_key se almacenan en un archivo .env. Debido a Vite, la herramienta de compilación en el corazón de SvelteKit, es necesario prefijar las variables de entorno con VITE_ para que sean accesibles en SvelteKit.

Acceso al juego

Quería que unirse a un juego de Autowuzzler fuera tan fácil como seguir un enlace. Por lo tanto, cada sala de juegos necesitaba tener su propia URL basada en el PIN del juego creado anteriormente , por ejemplo, https://autowuzzler.com/play/12345.

En SvelteKit, las páginas con parámetros de ruta dinámicos se crean colocando las partes dinámicas de la ruta entre corchetes al nombrar el archivo de la página: client/src/routes/play/[gamePIN].svelte . El valor del parámetro gamePIN estará disponible en el componente de la página (consulte los documentos de SvelteKit para obtener más información). En la ruta de play , debemos conectarnos al servidor Colyseus, instanciar el mundo de la física para mostrarlo en la pantalla, manejar las actualizaciones de los objetos del juego, escuchar la entrada del teclado y mostrar otra interfaz de usuario como la partitura, etc.

Conexión a Colyseus y estado de actualización

La biblioteca cliente Colyseus nos permite conectar un cliente a un servidor Colyseus. Primero, creemos un nuevo Colyseus.Client apuntándolo al servidor Colyseus ( ws://localhost:2567 en desarrollo). Luego únase a la sala con el nombre que elegimos anteriormente ( autowuzzler ) y el gamePIN del parámetro de ruta. El parámetro gamePIN se asegura de que el usuario se una a la instancia de sala correcta (ver "emparejamiento" más arriba).

 let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN });

Dado que SvelteKit muestra las páginas en el servidor inicialmente, debemos asegurarnos de que este código solo se ejecute en el cliente una vez que la página haya terminado de cargarse. Nuevamente, usamos la función de ciclo de vida onMount para ese caso de uso. (Si está familiarizado con React, onMount es similar al gancho useEffect con una matriz de dependencia vacía).

 onMount(async () => { let client = new Colyseus.Client("ws://localhost:2567"); this.room = await client.joinOrCreate("autowuzzler", { gamePIN }); })

Ahora que estamos conectados al servidor del juego Colyseus, podemos comenzar a escuchar cualquier cambio en nuestros objetos del juego.

Aquí hay un ejemplo de cómo escuchar a un jugador que se une a la sala ( onAdd ) y recibe actualizaciones de estado consecutivas para este jugador:

 this.room.state.players.onAdd = (player, key) => { console.log(`Player has been added with sessionId: ${key}`); // add player entity to the game world this.world.createPlayer(key, player.teamNumber); // listen for changes to this player player.onChange = (changes) => { changes.forEach(({ field, value }) => { this.world.updatePlayer(key, field, value); // see below }); }; };

En el método updatePlayer del mundo de la física, actualizamos las propiedades una por una porque onChange de onChange ofrece un conjunto de todas las propiedades modificadas.

Nota : esta función solo se ejecuta en la versión del cliente del mundo de la física, ya que los objetos del juego solo se manipulan indirectamente a través del servidor Colyseus.

 updatePlayer(sessionId, field, value) { // get the player physics object by its sessionId let player = this.world.players.get(sessionId); // exit if not found if (!player) return; // apply changes to the properties switch (field) { case "angle": Body.setAngle(player, value); break; case "x": Body.setPosition(player, { x: value, y: player.position.y }); break; case "y": Body.setPosition(player, { x: player.position.x, y: value }); break; // set velocityX, velocityY, angularVelocity ... } }

El mismo procedimiento se aplica a los otros objetos del juego (pelota y equipos): escuche sus cambios y aplique los valores modificados al mundo físico del cliente.

Hasta ahora, ningún objeto se mueve porque aún necesitamos escuchar la entrada del teclado y enviarla al servidor . En lugar de enviar eventos directamente en cada evento de keydown de tecla, mantenemos un mapa de las teclas presionadas actualmente y enviamos eventos al servidor Colyseus en un bucle de 50 ms. De esta forma, podemos admitir la pulsación de varias teclas al mismo tiempo y mitigar la pausa que se produce después del primer y consecutivos eventos keydown de tecla cuando la tecla permanece pulsada:

 let keys = {}; const keyDown = e => { keys[e.key] = true; }; const keyUp = e => { keys[e.key] = false; }; document.addEventListener('keydown', keyDown); document.addEventListener('keyup', keyUp); let loop = () => { if (keys["ArrowLeft"]) { this.room.send("move", { direction: "left" }); } else if (keys["ArrowRight"]) { this.room.send("move", { direction: "right" }); } if (keys["ArrowUp"]) { this.room.send("move", { direction: "up" }); } else if (keys["ArrowDown"]) { this.room.send("move", { direction: "down" }); } // next iteration requestAnimationFrame(() => { setTimeout(loop, 50); }); } // start loop setTimeout(loop, 50);

Ahora el ciclo está completo: escuche las pulsaciones de teclas, envíe los comandos correspondientes al servidor Colyseus para manipular el mundo de la física en el servidor. Luego, el servidor Colyseus aplica las nuevas propiedades físicas a todos los objetos del juego y propaga los datos al cliente para actualizar la instancia del juego orientada al usuario.

Molestias menores

En retrospectiva, dos cosas de la categoría nadie-me-dijo-pero-alguien-debería-haberme venido a la mente:

  • Una buena comprensión de cómo funcionan los motores de física es beneficiosa. Pasé una cantidad considerable de tiempo ajustando las propiedades y restricciones físicas. A pesar de que construí un pequeño juego con Phaser.js y Matter.js antes, hubo muchas pruebas y errores para lograr que los objetos se movieran de la forma en que los imaginaba.
  • El tiempo real es difícil , especialmente en los juegos basados ​​en la física. Los retrasos menores empeoran considerablemente la experiencia y, aunque la sincronización del estado entre clientes con Colyseus funciona muy bien, no puede eliminar los retrasos en el cálculo y la transmisión.

Gotchas y advertencias con SvelteKit

Dado que usé SvelteKit recién salido del horno beta, hubo algunas trampas y advertencias que me gustaría señalar:

  • Me tomó un tiempo darme cuenta de que las variables de entorno deben tener el prefijo VITE_ para poder usarlas en SvelteKit. Esto ahora está debidamente documentado en las preguntas frecuentes.
  • Para usar Supabase, tuve que agregar Supabase a las listas de dependencies y devDependencies de package.json. Creo que esto ya no es así.
  • ¡La función de load de SvelteKits se ejecuta tanto en el servidor como en el cliente!
  • Para habilitar el reemplazo completo del módulo activo (incluido el estado de conservación), debe agregar manualmente una línea de comentario <!-- @hmr:keep-all --> en los componentes de su página. Consulte las preguntas frecuentes para obtener más detalles.

Muchos otros marcos también habrían sido excelentes, pero no me arrepiento de haber elegido SvelteKit para este proyecto. Me permitió trabajar en la aplicación cliente de una manera muy eficiente, principalmente porque Svelte en sí mismo es muy expresivo y se salta gran parte del código repetitivo, pero también porque Svelte tiene cosas como animaciones, transiciones, CSS con alcance y tiendas globales integradas. SvelteKit proporcionó todos los componentes básicos que necesitaba (SSR, enrutamiento, rutas de servidor) y, aunque todavía estaba en versión beta, se sentía muy estable y rápido.

Implementación y hospedaje

Inicialmente, alojé el servidor Colyseus (Node) en una instancia de Heroku y perdí mucho tiempo haciendo funcionar WebSockets y CORS. Resulta que el rendimiento de un diminuto (gratuito) dinamómetro Heroku no es suficiente para un caso de uso en tiempo real. Más tarde migré la aplicación Colyseus a un pequeño servidor en Linode. La aplicación del lado del cliente es implementada y alojada en Netlify a través del adaptador SvelteKits-netlify. No hay sorpresas aquí: ¡Netlify funcionó muy bien!

Conclusión

Comenzar con un prototipo realmente simple para validar la idea me ayudó mucho a determinar si valía la pena seguir el proyecto y dónde se encuentran los desafíos técnicos del juego. En la implementación final, Colyseus se encargó de todo el trabajo pesado de sincronizar el estado en tiempo real entre múltiples clientes, distribuidos en múltiples salas. Es impresionante lo rápido que se puede crear una aplicación multiusuario en tiempo real con Colyseus, una vez que descubre cómo describir correctamente el esquema. El panel de monitoreo incorporado de Colyseus ayuda a solucionar cualquier problema de sincronización.

Lo que complicó esta configuración fue la capa de física del juego porque introdujo una copia adicional de cada objeto del juego relacionado con la física que necesitaba mantenimiento. Almacenar los PIN del juego en Supabase.io desde la aplicación SvelteKit fue muy sencillo. En retrospectiva, podría haber usado una base de datos SQLite para almacenar los PIN del juego, pero probar cosas nuevas es la mitad de la diversión al crear proyectos paralelos.

Finalmente, usar SvelteKit para construir la interfaz del juego me permitió moverme rápidamente y con una sonrisa ocasional de alegría en mi rostro.

Ahora, ¡anímate e invita a tus amigos a una ronda de Autowuzzler!

Lectura adicional en la revista Smashing

  • “Comience con React construyendo un juego Whac-A-Mole”, Jhey Tompkins
  • "Cómo construir un juego de realidad virtual multijugador en tiempo real", Alvin Wan
  • “Escribir un motor de aventuras de texto multijugador en Node.js”, Fernando Doglio
  • “El futuro del diseño web móvil: diseño de videojuegos y narración”, Suzanne Scacca
  • “Cómo construir un juego de corredor sin fin en realidad virtual”, Alvin Wan