En uno de los artículos
anteriores , revisamos las tecnologías que se utilizan en nuestro nuevo proyecto: un juego de disparos rápido para dispositivos móviles. Ahora quiero compartir cómo se organiza la parte del cliente del código de red del juego futuro, qué dificultades encontramos y cómo resolverlas.

En general, los enfoques para crear juegos multijugador rápidos en los últimos 20 años no han cambiado mucho. Se pueden distinguir varios métodos en la arquitectura del código de red:
- Cálculo erróneo del estado del mundo en el servidor y visualización de los resultados en el cliente sin predicción para el jugador local y con la posibilidad de perder la entrada (entrada) del jugador. Este enfoque, por cierto, se utiliza en nuestro otro proyecto en desarrollo; puede leer sobre esto aquí .
- Paso a paso
- Sincronización del estado del mundo sin lógica determinista con predicción para un jugador local.
- Sincronización de entrada con lógica y predicción totalmente determinista para un jugador local.
La peculiaridad radica en el hecho de que en los tiradores lo más importante es la capacidad de respuesta del control: el jugador presiona un botón (o mueve el joystick) y quiere ver de inmediato el resultado de su acción. En primer lugar, porque el estado del mundo en tales juegos cambia muy rápidamente y es necesario responder de inmediato a la situación.
Como resultado de esto, los enfoques sin el mecanismo de predicción de las acciones del jugador local (predicción) no eran adecuados para el proyecto, y nos decidimos por un método para sincronizar el estado del mundo, sin una lógica determinista.
Ventaja del enfoque: menor complejidad en la implementación en comparación con el método de sincronización al intercambiar entradas.
Menos: un aumento en el tráfico al enviar todo el estado del mundo al cliente. Tuvimos que aplicar varias técnicas diferentes de optimización del tráfico para que el juego funcionara de manera estable en una red móvil.
En el corazón de la arquitectura de juego tenemos ECS, del que ya hemos
hablado . Esta arquitectura le permite almacenar convenientemente datos sobre el mundo del juego, serializarlos, copiarlos y transferirlos a través de la red. Y también para ejecutar el mismo código tanto en el cliente como en el servidor.
La simulación del mundo del juego tiene lugar a una frecuencia fija de 30 tics por segundo. Esto le permite reducir el retraso en la entrada del reproductor y casi no utiliza la interpolación para mostrar visualmente el estado del mundo. Pero hay un inconveniente importante que debe tenerse en cuenta al desarrollar dicho sistema: para que el sistema de predicción del jugador local funcione correctamente, el cliente debe simular el mundo con la misma frecuencia que el servidor. Y pasamos mucho tiempo para optimizar la simulación lo suficiente para los dispositivos de destino.
Mecanismo de predicción de acción del jugador local (predicción)
El mecanismo de predicción del cliente se implementa sobre la base de ECS debido a la ejecución de los mismos sistemas tanto en el cliente como en el servidor. Sin embargo, no todos los sistemas se ejecutan en el cliente, sino solo aquellos que son responsables del jugador local y no requieren datos relevantes sobre otros jugadores.
Ejemplo de listas de sistemas que se ejecutan en el cliente y el servidor:

En este momento, tenemos unos 30 sistemas que se ejecutan en el cliente que proporcionan la predicción del jugador y unos 80 sistemas que se ejecutan en el servidor. Pero no predecimos cosas como infligir daño, usar habilidades o curar aliados. Hay dos problemas en esta mecánica:
- El cliente no sabe nada acerca de ingresar a otros jugadores y predecir cosas como daño o curación casi siempre diferirá de los datos en el servidor.
- Crear nuevas entidades localmente (disparos, proyectiles, habilidades únicas) generadas por un jugador conlleva el problema de emparejar con entidades creadas en el servidor.
Para una mecánica así, el retraso se oculta del jugador de otras maneras.
Ejemplo: sacamos el efecto de golpear del disparo inmediatamente, y actualizamos la vida del enemigo solo después de recibir la confirmación del golpe del servidor.El esquema general del código de red en el proyecto.

El cliente y el servidor sincronizan la hora por números de marca. Debido al hecho de que la transmisión de datos a través de la red lleva algún tiempo, el cliente siempre está por delante del servidor a la mitad
RTT + el tamaño del búfer de entrada en el servidor. El diagrama anterior muestra que el cliente envía una entrada para la marca 20 (a). Al mismo tiempo, la marca 15 (b) se procesa en el servidor. Para cuando la entrada del cliente llegue al servidor, la marca 20 se procesará en el servidor.
Todo el proceso consta de los siguientes pasos: el cliente envía la entrada del jugador al servidor (a) → esta entrada se procesa en el servidor después de HRTT + tamaño del búfer de entrada (b) → el servidor envía el estado mundial resultante al cliente (s) → el cliente aplica el estado mundial confirmado con hora del servidor RTT + tamaño del búfer de entrada + tamaño del búfer de interpolación del estado del juego (d).
Después de que el cliente recibe un nuevo estado confirmado del mundo del servidor (d), debe completar el proceso de reconciliación. El hecho es que el cliente realiza una predicción mundial basada solo en la aportación del jugador local. Las entradas de otros jugadores no son conocidas por él. Y al calcular el estado del mundo en el servidor, el jugador puede estar en un estado diferente, diferente de lo que predijo el cliente. Esto puede suceder cuando un jugador es aturdido o asesinado.
El proceso de aprobación consta de dos partes:
- Comparaciones del estado predicho del mundo para la marca N recibida del servidor. Solo los datos relacionados con el jugador local están involucrados en la comparación. El resto de los datos del mundo siempre se toman del estado del servidor y no participan en la coordinación.
- Durante la comparación, pueden ocurrir dos casos:
- si el estado previsto del mundo coincide con el confirmado del servidor, entonces el cliente, utilizando los datos pronosticados para el jugador local y los nuevos datos para el resto del mundo, continúa simulando el mundo en modo normal;
- si el estado predicho no coincidía, entonces el cliente usa el estado del servidor completo del mundo y el historial de entrada del cliente y relata el nuevo estado predicho del mundo del jugador.
En código, se parece a esto:GameState Reconcile(int currentTick, ServerGameStateData serverStateData, GameState currentState, uint playerID) { var serverState = serverStateData.GameState; var serverTick = serverState.Time; var predictedState = _localStateHistory.Get(serverTick);
La comparación de dos estados mundiales se produce solo para aquellos datos que se relacionan con el jugador local y participan en el sistema de predicción. Los datos se muestrean por ID de jugador.
Método de comparación: public bool IsSame(GameState s1, GameState s2, uint avatarId) { if (s1 == null && s2 != null || s1 != null && s2 == null) return false; if (s1 == null && s2 == null) return false; var entity1 = s1.WorldState[avatarId]; var entity2 = s2.WorldState[avatarId]; if (entity1 == null && entity2 == null) return false; if (entity1 == null || entity2 == null) return false; if (s1.Time != s2.Time) return false; if (s1.WorldState.Transform[avatarId] != s2.WorldState.Transform[avatarId]) return false; foreach (var s1Weapon in s1.WorldState.Weapon) { if (s1Weapon.Value.Owner.Id != avatarId) continue; var s2Weapon = s2.WorldState.Weapon[s1Weapon.Key]; if (s1Weapon.Value != s2Weapon) return false; var s1Ammo = s1.WorldState.WeaponAmmo[s1Weapon.Key]; var s2Ammo = s2.WorldState.WeaponAmmo[s1Weapon.Key]; if (s1Ammo != s2Ammo) return false; var s1Reload = s1.WorldState.WeaponReloading[s1Weapon.Key]; var s2Reload = s2.WorldState.WeaponReloading[s1Weapon.Key]; if (s1Reload != s2Reload) return false; } if (entity1.Aiming != entity2.Aiming) return false; if (entity1.ChangeWeapon != entity2.ChangeWeapon) return false; return true; }
Los operadores de comparación para componentes específicos se generan junto con toda la estructura EC, especialmente escrita por un generador de código. Por ejemplo, daré el código generado del operador de comparación de componentes Transformar:
Código public static bool operator ==(Transform a, Transform b) { if ((object)a == null && (object)b == null) return true; if ((object)a == null && (object)b != null) return false; if ((object)a != null && (object)b == null) return false; if (Math.Abs(a.Angle - b.Angle) > 0.01f) return false; if (Math.Abs(a.Position.x - b.Position.x) > 0.01f || Math.Abs(a.Position.y - b.Position.y) > 0.01f) return false; return true; }
Cabe señalar que nuestros valores Float se comparan con un error bastante alto. Esto se hace para reducir la cantidad de desincronización entre el cliente y el servidor. Para el jugador, dicho error será invisible, pero esto ahorra significativamente los recursos informáticos del sistema.
La complejidad del mecanismo de coordinación es que, en caso de una mala sincronización de los estados del cliente y del servidor (predicción errónea), es necesario simular repetidamente todos los estados del cliente predichos sobre los que no hay confirmación del servidor, hasta el tic actual en un cuadro. Dependiendo del ping del jugador, esto puede ser de 5 a 20 tics de simulación. Tuvimos que optimizar significativamente el código de simulación para encajar en el marco de tiempo: 30 fps.
Para completar el proceso de aprobación, se deben almacenar dos tipos de datos en el cliente:
- Una historia de estados de jugador predichos.
- Y la historia de la entrada.
Para estos fines, utilizamos un búfer circular. El tamaño del búfer es de 32 ticks. Que a una frecuencia de 30 HZ da aproximadamente 1 segundo de tiempo real. El cliente puede continuar trabajando de manera segura en el mecanismo de predicción, sin recibir nuevos datos del servidor, hasta llenar este búfer. Si la diferencia entre la hora del cliente y el servidor comienza a ser más de un segundo, el cliente se ve obligado a desconectarse con un intento de reconexión. Tenemos tal tamaño de amortiguación debido a los costos del proceso de coordinación en caso de una discrepancia entre los estados del mundo. Pero si la diferencia entre el cliente y el servidor es más de un segundo, es más barato realizar una reconexión completa al servidor.
Reducción del tiempo de retraso
El diagrama anterior muestra que en el juego hay dos buffers en el esquema de transferencia de datos:
- búfer de entrada en el servidor;
- un búfer de estados mundiales en el cliente.
El propósito de estos búferes es el mismo: compensar los saltos de red (jitter). El hecho es que la transmisión de paquetes a través de la red es desigual. Y dado que el motor de red funciona a una frecuencia fija de 30 HZ, los datos se deben suministrar al motor a la misma frecuencia. No tenemos la oportunidad de "esperar" algunos ms hasta que el próximo paquete llegue al destinatario. Utilizamos buffers para datos de entrada y estados mundiales con el fin de tener un margen de tiempo para la compensación de jitter. También usamos el búfer gamestate para la interpolación si se pierde uno de los paquetes.
Al comienzo del juego, el cliente inicia la sincronización con el servidor solo después de recibir varios estados mundiales del servidor y el búfer de estado del juego está lleno. Por lo general, el tamaño de este búfer es de 3 ticks (100 ms).
Al mismo tiempo, cuando el cliente se sincroniza con el servidor, "corre" por delante del tiempo del servidor por el valor del búfer de entrada en el servidor. Es decir el cliente mismo controla qué tan lejos está el servidor. El tamaño inicial del búfer de entrada también es igual a 3 ticks (100 ms).
Inicialmente, implementamos el tamaño de estos búferes como constantes. Es decir independientemente de si el jitter realmente existía en la red o no, hubo un retraso fijo de 200 ms (tamaño del búfer de entrada + tamaño del búfer del estado del juego) para actualizar los datos. Si agregamos a esto el ping promedio estimado en dispositivos móviles en algún lugar alrededor de 200 ms, entonces el retraso real entre el uso de la entrada en el cliente y la confirmación de la aplicación desde el servidor fue de 400 ms.
Esto no nos vino bien.
El hecho es que algunos sistemas se ejecutan solo en el servidor, como, por ejemplo, calcular el HP del jugador. Con este retraso, el jugador dispara y solo después de 400 ms ve cómo mata al oponente. Si esto sucedió en movimiento, generalmente el jugador logró correr detrás de la pared o para cubrirse y ya estaba muriendo allí. Las pruebas de juego dentro del equipo mostraron que tal demora rompe por completo toda la jugabilidad.
La solución a este problema fue la implementación de tamaños dinámicos de buffers de entrada y estados de juego:
- para un búfer gamestate, el cliente siempre conoce el contenido actual del búfer. Al momento de calcular el siguiente tick, el cliente verifica cuántos estados ya están en el búfer;
- para el búfer de entrada: el servidor, además del estado del juego, comenzó a enviar al cliente el valor del llenado actual del búfer de entrada para un cliente específico. El cliente a su vez analiza estos dos valores.
El algoritmo de cambio de tamaño del búfer gamestate es aproximadamente el siguiente:
- El cliente considera el valor promedio del tamaño del búfer durante un período de tiempo y variación.
- Si la variación está dentro de los límites normales (es decir, durante un período de tiempo determinado no hubo grandes saltos en el llenado y la lectura del búfer), el cliente verifica el valor del tamaño promedio del búfer para este período de tiempo.
- Si el llenado promedio del búfer fue mayor que la condición de límite superior (es decir, el búfer se llenaría más de lo requerido), el cliente "reduce" el tamaño del búfer realizando una marca de simulación adicional.
- Si el llenado promedio del búfer fue menor que la condición de límite inferior (es decir, el búfer no tuvo tiempo de llenarse antes de que el cliente comenzara a leerlo), en este caso, el cliente "aumenta" el tamaño del búfer omitiendo una marca de la simulación.
- En el caso de que la varianza fuera superior a lo normal, no podemos confiar en estos datos, porque las sobretensiones de red durante un período de tiempo determinado fueron demasiado grandes. Luego, el cliente descarta todos los datos actuales y comienza a recopilar estadísticas nuevamente.
Compensación de retraso del servidor
Debido al hecho de que el cliente recibe actualizaciones mundiales del servidor con un retraso (retraso), el jugador ve el mundo un poco diferente de lo que existe en el servidor. El jugador se ve a sí mismo en el presente y al resto del mundo, en el pasado. En el servidor, todo el mundo existe en una sola vez.

Debido a esto, la situación es que el jugador dispara localmente a un objetivo que se encuentra en el servidor en otro lugar.
Para compensar el retraso, utilizamos el rebobinado de tiempo en el servidor. El algoritmo de operación es aproximadamente el siguiente:
- El cliente con cada entrada adicionalmente envía al servidor el tiempo de tic en el que ve el resto del mundo.
- El servidor valida este tiempo: es la diferencia entre la hora actual y la hora visible del mundo del cliente en el intervalo de confianza.
- Si la hora es válida, el servidor deja al jugador en la hora actual y el resto del mundo regresa al estado que vio el jugador y calcula el resultado del disparo.
- Si un jugador golpea, el daño se hace en el tiempo actual del servidor.
El tiempo de rebobinado en un servidor funciona de la siguiente manera: la historia del mundo (en ECS) y la historia de la física (compatible con el motor de
Física volátil ) se almacenan en el norte. En el momento en que se calculó el tiro, los datos del jugador se toman del estado actual del mundo y los jugadores restantes del historial.
El código para el sistema de validación de disparos se ve así: public void Execute(GameState gs) { foreach (var shotPair in gs.WorldState.Shot) { var shot = shotPair.Value; var shooter = gs.WorldState[shotPair.Key]; var shooterTransform = shooter.Transform; var weaponStats = gs.WorldState.WeaponStats[shot.WeaponId];
Un inconveniente significativo en el enfoque es que confiamos en el cliente en los datos sobre el momento de la marca que ve. Potencialmente, un jugador puede obtener una ventaja al aumentar artificialmente el ping. Porque cuanto más ping tiene un jugador, más lejos dispara en el pasado.
Algunos problemas que encontramos
Durante la implementación de este motor de red, encontramos muchos problemas, algunos de los cuales son dignos de un artículo separado, pero aquí mencionaré solo algunos de ellos.
Simulación de todo el mundo en un sistema de predicción y copia.
Inicialmente, todos los sistemas en nuestro ECS tenían solo un método: void Execute (GameState gs). En este método, los componentes relacionados con todos los jugadores generalmente se procesaron.
Un ejemplo de un sistema de movimiento en la implementación inicial: public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { var transform = gs.WorldState.Transform[movementPair.Key]; transform.Position += movementPair.Value.Velocity * GameState.TickDuration; } } }
Pero en el sistema de predicción de jugadores locales, solo necesitábamos procesar los componentes relacionados con un jugador específico. Inicialmente, implementamos esto usando copy.
El proceso de predicción fue el siguiente:
- Se creó una copia del estado del juego.
- Se proporcionó una copia a la entrada de ECS.
- Hubo una simulación de todo el mundo en ECS.
- Todos los datos relacionados con el jugador local se copiaron del estado de juego recién recibido.
El método de predicción se veía así: void PredictNewState(GameState state) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _tempGameState.Copy(state); _ecsExecutor.Execute(_tempGameState, input); _playerEntitiesCopier.Copy(_tempGameState, newState); }
Hubo dos problemas en esta implementación:
- Porque utilizamos clases, no estructuras: la copia es una operación bastante costosa para nosotros (aproximadamente 0.1-0.15 ms en iPhone 5S).
- La simulación de todo el mundo también lleva mucho tiempo (aproximadamente 1.5-2 ms en el iPhone 5S).
Si tenemos en cuenta que durante el proceso de coordinación es necesario volver a calcular de 5 a 15 estados mundiales en un solo marco, entonces con tal implementación todo fue terriblemente lento.
La solución era bastante simple: aprender a simular el mundo en partes, es decir, simular solo un jugador específico. Reescribimos todos los sistemas para que pueda transferir la identificación del jugador y simular solo a él.
Un ejemplo de un sistema de movimiento después de un cambio: public sealed class MovementSystem : ISystem { public void Execute(GameState gs) { foreach (var movementPair in gs.WorldState.Movement) { Move(gs.WorldState.Transform[movementPair.Key], movementPair.Value); } } public void ExecutePlayer(GameState gs, uint playerId) { var movement = gs.WorldState.Movement[playerId]; if(movement != null) { Move(gs.WorldState.Transform[playerId], movement); } } private void Move(Transform transform, Movement movement) { transform.Position += movement.Velocity * GameState.TickDuration; } }
Después de los cambios, pudimos eliminar las copias innecesarias en el sistema de predicción y reducir la carga en el sistema correspondiente.
Código: void PredictNewState(GameState state, uint playerId) { var newState = _stateHistory.Get(state.Tick+1); var input = _inputHistory.Get(state.Tick); newState.Copy(state); _ecsExecutor.Execute(newState, input, playerId); }
Crear y eliminar entidades en un sistema de predicción
En nuestro sistema, la coincidencia de entidades en el servidor y el cliente ocurre por un identificador entero (id). Para todas las entidades, utilizamos una numeración de identificadores de extremo a extremo, cada nueva entidad tiene el valor id = oldID + 1.
Este enfoque es muy conveniente de implementar, pero tiene un inconveniente importante: el orden de creación de nuevas entidades en el cliente y el servidor puede ser diferente y, como resultado, los identificadores de las entidades serán diferentes.
Este problema se manifestó cuando implementamos un sistema para predecir los tiros de los jugadores. Cada disparo con nosotros es una entidad separada con el componente de disparo. Para cada cliente, la identificación de las entidades de disparo en el sistema de predicción era secuencial. Pero si en el mismo momento otro jugador disparó, entonces en el servidor la identificación de todos los disparos difería del cliente.
Las tomas en el servidor se crearon en un orden diferente:

Para los disparos, eludimos esta limitación, en función de las características de juego del juego. Los disparos son entidades de vida rápida que se destruyen en el sistema una fracción de segundo después de la creación. En el cliente, resaltamos un rango separado de ID que no se cruzan con las ID del servidor y ya no tienen en cuenta las tomas en el sistema de coordinación. Es decir Los disparos de los jugadores locales siempre se dibujan en el juego solo de acuerdo con el sistema de predicción y no tienen en cuenta los datos del servidor.
Con este enfoque, el jugador no ve artefactos en la pantalla (eliminación, recreación, retrocesos de disparos), y las discrepancias con el servidor son menores y no afectan el juego en su conjunto.
Este método permitió resolver el problema con disparos, pero no todo el problema de crear entidades en el cliente como un todo. Todavía estamos trabajando en posibles métodos para resolver la comparación de objetos creados en el cliente y el servidor.
También se debe tener en cuenta que este problema solo se refiere a la creación de nuevas entidades (con nuevas ID). La adición y eliminación de componentes en entidades ya creadas se realiza sin problemas: los componentes no tienen identificadores y cada entidad solo puede tener un componente de un tipo específico. Por lo tanto, generalmente creamos entidades en el servidor, y en los sistemas de predicción solo agregamos / eliminamos componentes.
En conclusión, quiero decir que la tarea de implementar el modo multijugador no es la más fácil y rápida, pero hay mucha información sobre cómo hacerlo.
Que leer