Llevamos un juego multijugador de C ++ a la web con Cheerp, WebRTC y Firebase

Introduccion


Nuestra empresa Leaning Technologies ofrece soluciones para portar aplicaciones de escritorio tradicionales a la web. Nuestro compilador Cheerp de C ++ genera una combinación de WebAssembly y JavaScript, que proporciona una interacción fácil con el navegador y un alto rendimiento.

Como ejemplo de su aplicación, decidimos portar un juego multijugador para la web y elegimos Teeworlds para esto. Teeworlds es un juego retro bidimensional para varios jugadores con una pequeña pero activa comunidad de jugadores (¡incluido yo!). Es pequeño en términos de recursos descargables y requisitos de CPU y GPU, un candidato ideal.


Funciona en el navegador Teeworlds

Decidimos utilizar este proyecto para experimentar con soluciones generales para portar código de red a la web . Esto generalmente se hace de las siguientes maneras:

  • XMLHttpRequest / fetch si la parte de la red consta solo de solicitudes HTTP, o
  • WebSockets

Ambas soluciones requieren alojar el componente del servidor en el lado del servidor, y ninguna de ellas le permite utilizar UDP como protocolo de transporte. Esto es importante para aplicaciones en tiempo real, como videoconferencias y software de juegos, porque las garantías de entrega y el pedido de paquetes TCP pueden interferir con bajas latencias.

Hay una tercera forma: usar la red desde un navegador: WebRTC .

RTCDataChannel admite transmisiones confiables y poco confiables (en el último caso, si es posible, intenta usar UDP como protocolo de transporte), y puede usarse tanto con un servidor remoto como entre navegadores. Esto significa que podemos transferir toda la aplicación al navegador, ¡incluido el componente del servidor!

Sin embargo, esta es una dificultad adicional: antes de que dos pares de WebRTC puedan intercambiar datos, deben realizar un procedimiento de protocolo de enlace relativamente complicado para la conexión, que requiere varias entidades de terceros (un servidor de señal y uno o más servidores STUN / TURN ).

Idealmente, nos gustaría crear una API de red internamente utilizando WebRTC, pero lo más cerca posible de la interfaz UDP Sockets, que no necesita establecer una conexión.

Esto nos permitirá aprovechar WebRTC sin la necesidad de revelar detalles complejos al código de la aplicación (que queríamos cambiar lo menos posible en nuestro proyecto).

WebRTC mínimo


WebRTC es un conjunto de API disponible en los navegadores que proporciona audio, video y transferencia arbitraria de datos entre pares.

La conexión entre los pares se establece (incluso si hay NAT en uno o ambos lados) utilizando los servidores STUN y / o TURN a través de un mecanismo llamado ICE. Los pares intercambian información ICE y parámetros de canal a través de la oferta SDP y el protocolo de respuesta.

Wow! Cuántas abreviaturas a la vez. Expliquemos brevemente qué significan estos conceptos:

  • Utilidades transversales de sesión para NAT ( STUN ) : un protocolo para omitir NAT y recibir un par (IP, puerto) para intercambiar datos directamente con el host. Si logra completar su tarea, los compañeros pueden intercambiar datos de forma independiente entre sí.
  • El uso transversal de los relés alrededor de NAT ( TURN ) también se usa para omitir NAT, pero lo hace redirigiendo datos a través de un proxy que es visible para ambos pares. Agrega retraso y es más costoso de ejecutar que STUN (porque se usa durante toda la sesión de comunicación), pero a veces esta es la única opción posible.
  • El establecimiento de conectividad interactiva ( ICE ) se utiliza para seleccionar la mejor manera posible de conectar dos pares en función de la información obtenida conectando directamente a pares, así como la información recibida por cualquier número de servidores STUN y TURN.
  • El Protocolo de descripción de sesión ( SDP ) es un formato para describir los parámetros del canal de conexión, por ejemplo, candidatos ICE, códecs multimedia (en el caso de un canal de audio / video), etc. ... Uno de los pares envía una oferta SDP ("oferta"), y el segundo responde con SDP Respuesta ("respuesta"). Después de eso, se crea un canal.

Para crear dicha conexión, los pares deben recopilar la información que recibieron de los servidores STUN y TURN e intercambiarla entre sí.

El problema es que todavía no tienen la capacidad de intercambiar datos directamente, por lo que debe haber un mecanismo fuera de banda para intercambiar estos datos: un servidor de señales.

Un servidor de señales puede ser muy simple, porque su única tarea es redirigir los datos entre pares en la etapa de "apretón de manos" (como se muestra en el diagrama a continuación).


Secuencia de protocolo de enlace simplificado WebRTC

Descripción del modelo de red de Teeworlds


La arquitectura de red de Teeworlds es muy simple:

  • Los componentes de cliente y servidor son dos programas diferentes.
  • Los clientes ingresan al juego conectándose a uno de varios servidores, cada uno de los cuales aloja un solo juego a la vez.
  • Toda la transferencia de datos en el juego es a través del servidor.
  • Se usa un servidor maestro especial para recopilar una lista de todos los servidores públicos que se muestran en el cliente del juego.

Debido al uso de WebRTC para el intercambio de datos, podemos transferir el componente del servidor del juego al navegador donde se encuentra el cliente. Nos da una gran oportunidad ...

Deshazte de los servidores


La falta de lógica del servidor tiene una buena ventaja: podemos implementar toda la aplicación como contenido estático en las páginas de Github o en nuestro propio equipo detrás de Cloudflare, lo que garantiza descargas rápidas y un alto tiempo de actividad de forma gratuita. De hecho, podemos olvidarnos de ellos, y si tenemos suerte y el juego se vuelve popular, entonces la infraestructura no tendrá que modernizarse.

Sin embargo, para que el sistema funcione, todavía tenemos que usar una arquitectura externa:

  • Uno o más servidores STUN: tenemos la opción de varias opciones gratuitas.
  • Al menos un servidor TURN: no hay opciones gratuitas aquí, por lo que podemos configurar el nuestro o pagar el servicio. Afortunadamente, la mayoría de las veces puede conectarse a través de los servidores STUN (y proporcionar un verdadero p2p), pero TURN es necesario como alternativa.
  • Servidor de señal: a diferencia de los otros dos aspectos, la señalización no está estandarizada. El responsable del servidor de señales depende de alguna manera de la aplicación. En nuestro caso, antes de establecer una conexión, es necesario intercambiar una pequeña cantidad de datos.
  • Servidor maestro de Teeworlds: otros servidores lo utilizan para notificar su existencia y los clientes buscan servidores públicos. Aunque no es obligatorio (los clientes siempre pueden conectarse a un servidor que conocen manualmente), sería bueno tenerlo para que los jugadores puedan participar en juegos con personas aleatorias.

Decidimos usar los servidores STUN gratuitos de Google e implementamos un servidor TURN por nuestra cuenta.

Para los últimos dos puntos usamos Firebase :

  • El servidor maestro de Teeworlds se implementa de manera muy simple: como una lista de objetos que contienen información (nombre, IP, mapa, modo, ...) de cada servidor activo. Los servidores publican y actualizan su propio objeto, y los clientes toman la lista completa y se la muestran al jugador. También mostramos la lista en la página de inicio como HTML, para que los jugadores puedan simplemente hacer clic en el servidor e ir directamente al juego.
  • La señalización está estrechamente relacionada con nuestra implementación de socket, descrita en la siguiente sección.


Lista de servidores dentro del juego y en la página de inicio

Implementación de socket


Queremos crear una API lo más cerca posible de los sockets Posix UDP para minimizar la cantidad de cambios necesarios.

También queremos obtener el mínimo necesario para el intercambio de datos más simple en la red.

Por ejemplo, no necesitamos enrutamiento real: todos los pares están en la misma "LAN virtual" asociada con una instancia específica de la base de datos Firebase.

Por lo tanto, no necesitamos direcciones IP únicas: para la identificación única de pares, es suficiente usar valores únicos de claves Firebase (similares a los nombres de dominio), y cada par asigna localmente direcciones IP "falsas" a cada clave que necesita ser convertida. Esto elimina por completo la necesidad de una asignación de dirección IP global, que es una tarea no trivial.

Aquí está la API mínima que necesitamos implementar:

// Create and destroy a socket int socket(); int close(int fd); // Bind a socket to a port, and publish it on Firebase int bind(int fd, AddrInfo* addr); // Send a packet. This lazily create a WebRTC connection to the // peer when necessary int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr); // Receive the packets destined to this socket int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr); // Be notified when new packets arrived int recvCallback(Callback cb); // Obtain a local ip address for this peer key uint32_t resolve(client::String* key); // Get the peer key for this ip String* reverseResolve(uint32_t addr); // Get the local peer key String* local_key(); // Initialize the library with the given Firebase database and // WebRTc connection options void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice); 

La API es simple y similar a la API de Sockets Posix, pero tiene varias diferencias importantes: registrar devoluciones de llamada, asignar direcciones IP locales y una conexión diferida .

Registro de devolución de llamada


Incluso si el programa fuente usa E / S sin bloqueo, el código debe ser refactorizado para ejecutarse en un navegador web.

La razón de esto es que el bucle de eventos en el navegador está oculto del programa (ya sea JavaScript o WebAssembly).

En un entorno nativo, podemos escribir código de esta manera

 while(running) { select(...); // wait for I/O events while(true) { int r = readfrom(...); // try to read if (r < 0 && errno == EWOULDBLOCK) // no more data available break; ... } ... } 

Si el bucle de eventos está oculto para nosotros, entonces debemos convertirlo en algo como esto:

 auto cb = []() { // this will be called when new data is available while(true) { int r = readfrom(...); // try to read if (r < 0 && errno == EWOULDBLOCK) // no more data available break; ... } ... }; recvCallback(cb); // register the callback 

Asignación de IP local


Los identificadores de nodo en nuestra "red" no son direcciones IP, sino claves de Firebase (estas son líneas que se ven así: -LmEC50PYZLCiCP-vqde ).

Esto es conveniente porque no necesitamos un mecanismo para asignar IP y verificar su singularidad (así como su eliminación después de desconectar al cliente), pero a menudo es necesario identificar a los pares por un valor numérico.

Para esto, se utilizan las funciones resolve e reverseResolve : la aplicación de alguna manera obtiene el valor de cadena de la clave (a través de la entrada del usuario o del servidor maestro) y puede convertirlo a una dirección IP para uso interno. El resto de la API también obtiene este valor en lugar de una cadena por simplicidad.

Esto es similar a una búsqueda de DNS, solo se realiza localmente en el cliente.

Es decir, las direcciones IP no se pueden compartir entre diferentes clientes, y si necesita algún tipo de identificador global, tendrá que generarlo de una manera diferente.

Mezcla perezosa


UDP no necesita una conexión, pero, como vimos, antes de comenzar la transferencia de datos entre dos pares, WebRTC requiere un largo proceso de conexión.

Si queremos proporcionar el mismo nivel de abstracción ( sendto / recvfrom con pares arbitrarios sin conectar primero), entonces debemos hacer una conexión "perezosa" (retardada) dentro de la API.

Esto es lo que sucede durante el intercambio de datos normal entre el "servidor" y el "cliente" en caso de utilizar UDP, y lo que debe hacer nuestra biblioteca:

  • El servidor llama a bind() para indicarle al sistema operativo que desea recibir paquetes en el puerto especificado.

En su lugar, publicaremos el puerto abierto en Firebase bajo la clave del servidor y escucharemos los eventos en su subárbol.

  • El servidor llama a recvfrom() , aceptando paquetes de cualquier host a este puerto.

En nuestro caso, debemos verificar la cola entrante de los paquetes enviados a este puerto.

Cada puerto tiene su propia cola, y agregamos los puertos de origen y de destino al comienzo de los datagramas WebRTC para saber qué cola redirigir cuando llega un nuevo paquete.

La llamada no es de bloqueo, por lo que si no hay paquetes, simplemente devolvemos -1 y establecemos errno=EWOULDBLOCK .

  • El cliente recibe, por algún medio externo, la IP y el puerto del servidor, y llama a sendto() . Además, se realiza una llamada interna a bind() , por lo que recvfrom() posterior recibirá una respuesta sin ejecutar explícitamente bind.

En nuestro caso, el cliente recibe externamente la clave de cadena y utiliza la función resolve() para obtener la dirección IP.

En este punto, comenzamos el "apretón de manos" de WebRTC si los dos pares aún no están conectados entre sí. Las conexiones a diferentes puertos del mismo par utilizan el mismo DataRannel WebRTC.

También realizamos un bind() indirecto bind() para que el servidor pueda volver a conectarse en el próximo sendto() en caso de que se sendto() por algún motivo.

El servidor recibe una notificación cuando el cliente se conecta cuando el cliente escribe su oferta SDP bajo la información del puerto del servidor en Firebase, y el servidor responde con su propia respuesta.



El siguiente diagrama muestra un ejemplo del movimiento de mensajes para un esquema de socket y la transmisión del primer mensaje del cliente al servidor:


Diagrama completo de pasos de conexión entre el cliente y el servidor

Conclusión


Si has leído hasta el final, entonces probablemente estés interesado en mirar la teoría en acción. El juego se puede jugar en teeworlds.leaningtech.com , ¡pruébalo!


Partido amistoso entre colegas

El código de la biblioteca de red está disponible gratuitamente en Github . ¡Únete al chat en nuestro canal en Gitter !

Source: https://habr.com/ru/post/468031/


All Articles